78 Commits

Author SHA1 Message Date
Alexander 467f9a4e71 feat(tasks): vinyl sticker album + fridge-calendar rewards redesign
CI / update (push) Has been cancelled
Replace the pokedex grid on /tasks/rewards with a scrapbook "sticker album":
category pages on warm paper, die-cut glossy vinyl stickers (debossed
silhouettes for missing ones), rarity-scaled holo shine/foil/glow, and a
large sticker-themed detail popup on click. Pages sort by rarity (rarest
category + sticker first; "Allerlei" catch-all last); each category has an
info popover explaining how its stickers drop, with the /tasks tag icons.

Restyle the calendar as a cozy fridge wall-calendar (paper, washi tape,
Fredoka month, cats stuck on dates as tilted die-cut stickers, weekend
tint). Shows only the current user by default; tap a name in the monthly
tally to fold in the other household member (per-person colour dots).

Export ALWAYS_CATEGORIES + getTagsForCategory from stickers util.
2026-06-01 23:42:47 +02:00
Alexander 8bd794bccb perf(build): replace adapter precompress with a parallel, filtered step
adapter-node's `precompress: true` brotli-q11 + gzips every file in
build/client single-threaded — ~150 MB including 91 MB of already-
compressed media (zero gain) and tens of MB of server-only data — adding
minutes to the build. The worst offenders were ML embedding JSONs
(nutrition 35 MB + bls 20 MB + shopping ~4 MB) that are `?url`-imported by
$lib/server/{nutritionMatcher,shoppingCategorizer}.ts and read server-side
via SvelteKit's read(); no browser ever fetches them, so compressing them
is pure waste.

- svelte.config.js: precompress: false.
- scripts/precompress.ts: postbuild step that only compresses text asset
  types, skips binaries and server-only data (bible TSVs by name, embedding
  JSONs by hashed-name pattern), tunes brotli quality down for large files,
  runs gzip+brotli in parallel, and never writes a larger-than-original or
  duplicate sibling.
- package.json: run precompress after build-error-page with
  UV_THREADPOOL_SIZE=12 so the async compression actually parallelizes.
- Delete static/allioli.json: 20 MB, unreferenced anywhere in the repo.
2026-06-01 16:11:52 +02:00
Alexander f52d6b4d4b perf(tasks): show completion sticker instantly
Roll the sticker client-side and render the popup immediately on completion
instead of waiting for the POST roundtrip + DB writes to return it. Preload
the sticker image so the cat is decoded before the bounce-in finishes. The
chosen stickerId is sent to the server, which persists it (falling back to a
server-side roll if missing/invalid).
2026-06-01 16:05:54 +02:00
Alexander 9b5cfe5e49 fix(recipes): build deploy against .env_prod; harden image save
Recipe image upload failed in prod with ENOENT writing the full image.
Root cause: the deploy built against the dev .env, whose relative
IMAGE_DIR="./imgs/" resolves under the service's dist/ working dir
instead of the real served image directory — and `$env/static/private`
is inlined at build time, so dev values shipped to prod.

- deploy.sh: source .env_prod (overridable via PROD_ENV) into the env
  before `pnpm build`, so prod values win over .env for the whole build
  lifecycle; abort if it's missing rather than ship a dev-env build.
- .gitignore: ignore .env_* so .env_prod (prod secrets) isn't committed
  (the existing .env.* dot pattern didn't match the underscore form).
- imageProcessing: mkdir -p the full/thumb dirs before writing. The
  WebP passthrough writes the full image with fs.writeFile, which (unlike
  sharp's toFile) does not create parent dirs.
- recipeFormHelpers: add serializableFormValues() and use it in the add/
  edit actions' fail() returns. Returning raw formData (now containing the
  recipe_image File) crashed the action response with a non-POJO devalue
  error, masking the real failure with an opaque 500.
2026-05-31 13:49:04 +02:00
Alexander 9fe9d95e36 feat(hikes): unify below-map view transition into one sliding panel
The detail-page enter/exit transition previously slid only the metric
tiles up from the bottom — the wrapper had no background, so its
snapshot was transparent and no containing panel moved. The photo strip
also animated separately, sliding in from the right.

Wrap everything below the hero map (stage nav, photo strip, metrics,
tags, elevation, scroll area, footer) in one `.below-map` element with
`view-transition-name: hike-below-map` and an opaque background, so the
whole sheet — background included — slides up on enter and down on exit
as a single panel. Drop the obsolete hike-strip right-slide rules and
keyframes; rename hike-below-strip → hike-below-map.
2026-05-31 13:29:15 +02:00
Alexander cd7912fa8f feat(recipes): client-side image editor + modernize /add to match /edit
CI / update (push) Has been cancelled
Add a from-scratch photo editor (crop, max-resolution scale-to-fit,
WebP quality with live final-size + dimensions readout) that opens on
image pick in the recipe add/edit flow. Conversion uses the browser's
canvas WebP encoder (sharp can't run client-side); crop, scale and the
size readout are built by hand.

Server now stores the client WebP full image byte-for-byte (passthrough)
so the on-disk file matches the user's chosen quality/size; sharp still
derives the 800px thumb and OKLAB colour. Non-WebP uploads keep the old
q90 re-encode fallback.

Rework /add to reuse EditTitleImgParallax (parallax hero +
titleExtras/below-hero layout, shape-tile Backform, SaveFab + optional
translation), replacing the antiquated CardAdd card. Move the edit/remove
image controls into the hero, below the fixed header. Delete now-dead
CardAdd and RecipeEditor.
2026-05-30 15:54:28 +02:00
Alexander fb54f6907f fix(prayers): correct Latin/German/English typos in prayer texts
CI / update (push) Has been cancelled
Spelling and grammar fixes across prayer components (e.g. sanctífica,
peccávi, nequítiam, Hoffnung, Menschen, Geheimnisse), merge cæ ligature
in Credo, restore traditional clause order and close Ps. citation in the
Prayer Before a Crucifix.
2026-05-28 12:23:59 +02:00
Alexander 94c8212078 feat(tile-proxy): Thunderforest Outdoors as foreign karte upstream
OpenTopoMap's hypsometric tint reads "red mountains / green flats" and
looks nothing like the Swisstopo Pixelkarte the proxy hands out
in-region — produced a jarring visual seam right at the CH/LI border.
Thunderforest Outdoors has a muted topo palette + subtle hillshade
that matches the swisstopo tile aesthetic much more closely, so use
it as the abroad `karte` upstream when an API key is available.

- `tile-proxy/build.rs`: reads `tile-proxy/.env` (gitignored) at build
  time and forwards each `KEY=VAL` line to rustc as `--env`, so the
  key is baked into the binary via `option_env!` and never touched at
  runtime. A shell env var of the same name wins over the .env entry
  (dotenv precedence). `cargo:rerun-if-changed=.env` +
  `cargo:rerun-if-env-changed` force a recompile whenever the value
  changes — no stale key cached in the binary.
- `main.rs`: `THUNDERFOREST_API_KEY` read via `option_env!`; foreign
  `karte` is Thunderforest Outdoors when set, OpenTopoMap fallback
  when absent. Behaviour unchanged for keyless builds.
- `mapTiles.ts`: page-footer attribution credits Thunderforest + OSM
  alongside the existing swisstopo / OpenTopoMap / Esri lines so the
  attribution stays correct regardless of which build is deployed.
- `.gitignore`: tile-proxy build artefacts (binary, `target/`, `.env`)
  moved to the root gitignore with fully-qualified paths so the
  source tree isn't hidden by a nested gitignore quirk; the per-dir
  `tile-proxy/.gitignore` is removed.
- README + systemd service: documentation refreshed for the new
  build-time key flow.
2026-05-26 22:56:32 +02:00
Alexander ac76bfba34 feat(hikes): view-transition exit flow off detail page
Fires the same strip-slide-out + below-strip-fly-down animation whenever
a hike detail page is left for anywhere other than another slug — not
just on the back-to-/hikes path. Layout adds a new `vt-exit-hike-detail`
class on the document root for that case; the css rules tag onto the
existing `vt-enter-hikes` selectors so both exits drive identical
animations.
2026-05-26 22:54:35 +02:00
Alexander 0f6c50f854 feat(hikes): no-JS elevation SVG + static trail-col map
- Render the elevation profile as an inline SVG at SSR (filled area
  + 5 ticks per axis + soft horizontal helplines). Chart.js takes
  over via a sticky `chartReady` flag once it imports and paints,
  fading the SVG out.
- Pre-rendered medium hero now underlays the desktop trail-col map,
  cover-cropped and `transform: scale(2.25)`d so the bbox fills the
  slot. Fades on first leaflet tile-paint, same handover as the
  hero map further up.
- Wrap everything below the photo strip in `.below-strip` so the
  view-transition into the detail page can slide the metrics,
  tags, charts, scroll-area and footer as a single block.
2026-05-26 22:48:41 +02:00
Alexander 8a67f5fba8 feat(hikes): medium hero variant + Switzerland-framed overview, drop static→live wobble
Three related improvements to the pre-rendered hero map system:

* New medium viewport variant (561–899 CSS px) for the per-hike detail
  hero and the /hikes overview. Tablet/split-pane viewports were
  getting the wide pose (chosen for ~1920 CSS px), which landed too
  zoomed in. Each variant is rendered at a pose matching its
  container, so the static→Leaflet handover aligns at every band.
  Manifest fields are optional — pages fall back to the wide variant
  on tablets until build-hikes regenerates the images.

* Overview frames on Switzerland (fixed center [46.82, 8.23]) with
  explicit per-variant zooms (wide=8, medium=8, narrow=7) rather than
  auto-fitting the union of hike bboxes. The previous behavior zoomed
  in on whichever corner the catalogue clustered in; this reads as
  "hikes across CH". Bumps OVERVIEW_RENDER_VERSION so cached overview
  images get invalidated on the next build.

* Removed the post-tile-load flyToBounds in both HikeMap.svelte and
  HikesOverviewMap.svelte. The map already opens at the static pose
  via setView; the second auto-fit was adding a visible wobble on
  routes whose bbox sits at an integer-zoom boundary (e.g. the
  Einsiedeln–Unteriberg detail), where the build-time fit and
  Leaflet's runtime fit disagree by one zoom step at the user's
  actual container size.
2026-05-26 11:51:48 +02:00
Alexander b49a299371 feat(hikes): view-transition flow across /hikes ↔ /hikes/[slug]
Cards + filter bar fly up from below when arriving at /hikes and drop
back down when leaving (in both directions of /hikes ↔ detail). Clicked
card morphs into the detail hero with a cross-fade so the thumbnail
dissolves into the map instead of snapping. Photo strip slides in from
the right. Root content cross-fades so metrics + content under the hero
phase in rather than appear at the end of the morph.

Track JSON moves from a client-side $effect into +page.ts so the strip
is in the DOM at view-transition snapshot time — also kills the brief
layout shift when it used to pop in post-load.
2026-05-26 10:34:00 +02:00
Alexander f1c0304b14 fix(hikes): spelling, grammar + copy-paste cleanup in new hike prose
Spelling/grammar across the six new hikes; plus content fixes: drop schlittelwanderung's pasted rain/spring lines, monte-generosa's stray 'S-Bahn ab Einsiedeln', rewrite morgartenberg's Anreise/Heimreise to Biberegg/Sattel, and correct Verzascatal's region (Zentralschweiz -> Tessin).
2026-05-25 15:00:46 +02:00
Alexander 164fdb2916 chore(hikes): track Morgartenberg content 2026-05-25 14:54:05 +02:00
Alexander ae4adc4023 chore(hikes): track Spaziergang Uetliberg content 2026-05-25 14:53:55 +02:00
Alexander 17ccfa1b41 chore(hikes): track Verzascatal content 2026-05-25 14:53:50 +02:00
Alexander dfc3142eeb chore(hikes): track Monte Generosa content 2026-05-25 14:53:36 +02:00
Alexander fdea8416a0 chore(hikes): track Schlittelausflug Brün content 2026-05-25 14:53:32 +02:00
Alexander 399d57217a chore(hikes): track Flims Gletschermühlen content 2026-05-25 14:53:06 +02:00
Alexander 0e70e30738 chore(hikes): swap a Siebengipfelwanderung photo + adjust return time
Replace the last three indexed photos with a named src image and correct the JourneyPlanner return time to 14:00.
2026-05-25 14:52:46 +02:00
Alexander 1918d240db chore(hikes): add Pfäffikersee + Siebengipfelwanderung icons 2026-05-25 14:52:04 +02:00
Alexander 316a340494 perf(build): raise hike image-encode concurrency cap to 10
Allow up to 10 parallel image pipelines (was 4) on hosts with the cores to spare.
2026-05-25 14:51:55 +02:00
Alexander 59b4630746 refactor(hikes): route via self-hosted BRouter instance
Point routeBrouter at a local BRouter server ($BROUTER_URL, default http://127.0.0.1:17777) instead of the public brouter.de, matching the bundled brouter package + systemd service.
2026-05-25 14:51:46 +02:00
Alexander 8459327717 feat(route-builder): show live elevation profile under the map
Render ElevationProfile below the route-builder map, fed by the routed segments (elevations fill in as the swisstopo enrichment resolves). Add a $effect in ElevationProfile so its dataset rebuilds when the track changes live during editing.
2026-05-25 14:51:32 +02:00
Alexander a4c2efe4f3 feat(hikes): re-derive track altitudes from swisstopo + pre-commit hook
Replace noisy phone-GPS <ele> in every committed track.gpx with swisstopo swissALTI3D heights at each exact lat/lon (coordinates unchanged; phone altitude was off by up to ~430m).

- scripts/fix-altitudes.ts: batched swisstopo profile.json lookup, WGS84->LV95, disk-cached, keeps original ele for any out-of-CH point.
- .githooks/pre-commit: auto-corrects any added/modified track.gpx on commit and re-stages it; wired via package.json prepare -> core.hooksPath.
2026-05-25 14:47:05 +02:00
Alexander cb16b25444 chore(hikes): track Walenseewanderung content
Add Walenseewanderung hike (index.svx + track.gpx); fix Anreise text that was copy-pasted from the Siebengipfelwanderung (route is to Amden, not Maschgenkamm).
2026-05-25 13:17:15 +02:00
Alexander 583d1b724c chore(hikes): track Siebengipfelwanderung content
Add Siebengipfelwanderung hike (index.svx + track.gpx); fix 'Flusmerberg' → 'Flumserberg' typo in the prose.
2026-05-25 12:20:06 +02:00
Alexander 3fc539e6fb chore(hikes): track Pfäffikersee content
Add Spaziergang um den Pfäffikersee hike (index.svx + track.gpx); fix German spelling/agreement in the prose.
2026-05-25 12:17:16 +02:00
Alexander c2862f4c21 chore(hikes): track Klausenpasswanderung content
Add the Klausenpass hike (index.svx, track.gpx, icon.svg). Cover photo
and source images stay gitignored, re-encoded into static assets at build.
2026-05-24 20:55:20 +02:00
Alexander 38c3df8187 feat(images): responsive <Image>, gated private images + prose
Build-time image optimization plus auth-gated private content.

- <Image> (src/lib/components/Image.svelte): wraps @sveltejs/enhanced-img
  for public images under src/lib/assets/images/ (AVIF/WebP, multiple
  widths, lazy by default), plus a `private` mode for auth-gated images.
- Private images: scripts/build-private-images.ts encodes sources from
  src/lib/assets/private-images/ into private-assets/ (outside the bundle)
  and a manifest; served only via the auth-checked /private-images/
  endpoint (X-Accel-Redirect in prod, disk read in dev).
- HikeImage gains a `src` prose mode: build-hikes encodes non-waypoint
  images referenced in .svx and exposes them by filename (imagesByName);
  a `private` attr routes them through the gated /hikes/<slug>/private/ path.
- <Private> (src/lib/components/Private.svelte): renders prose only to
  logged-in viewers (cosmetic gating — text still ships in the bundle).
- deploy.sh rsyncs private-assets/; prod needs an nginx internal
  /protected-images/ location.
2026-05-24 20:53:22 +02:00
Alexander 530308033b fix(build): disable prerender crawl so build stops OOMing
`/hikes` is `prerender = true` and carries the global nav, so the
prerender crawler followed those links and tried to statically render
the whole dynamic, DB-/ML-backed app. SvelteKit prerenders inside a
heap-capped worker_threads worker, so this exhausted its heap
(ERR_WORKER_OUT_OF_MEMORY) and failed the build.

- svelte.config.js: prerender.crawl = false. The intended static set is
  fully described by `prerender = true` (/hikes) + the /errors/[status]
  EntryGenerator, so crawling is unneeded. Add a defensive
  handleHttpError that ignores /hikes/*/images/* 404s (those binaries
  live in hikes-assets/, served by nginx/dev-middleware, not /static).
- hooks.server.ts: skip init when `building` so builds don't connect to
  Mongo, start the payment scheduler, or warm the romcal cache.
- hikes/[slug]: set `prerender = false`, enforcing the intent its
  comment already stated.

Version 1.86.1 -> 1.86.2.
2026-05-24 15:41:39 +02:00
Alexander c155fc33b4 fix(hikes): keep stat value + descriptor together in the metrics row
The metrics flex row stretched each tile to the tall route-icon's height,
so the grid's auto rows spread apart and pushed the value and its label to
opposite halves. Center the items instead of stretching.
2026-05-24 10:17:45 +02:00
Alexander a2869c1d87 feat(hikes): anonymize GPX timestamps to 08:00 today
Re-base every track + image timestamp so each hike starts at 08:00 on the
build date, preserving all relative timing (total duration, per-stage gaps,
photo "nach X"). The per-hike track JSON is the single source for the page
metrics and the client-built GPX download, so both come out anonymized; the
real recording times stay only in the private source track.gpx.

Also close two stale-data leaks that would otherwise still expose real
times: sweep prior-build track.*.json (keep only the current hash) and
remove orphan slug dirs from static/ (renamed/deleted hikes).
2026-05-24 10:14:01 +02:00
Alexander a8902dcf11 chore(hikes): track Einsiedeln–Spital–Unteriberg content
Version this hike's writing (index.svx) and route (track.gpx); source
photos (cover.jpg, images/) stay out of git per the .gitignore rules.
2026-05-23 17:08:04 +02:00
Alexander c9b2773de4 chore(hikes): rsync push/pull for source photos
Add scripts/hike-photos.sh (+ pnpm photos:push / photos:pull) to back up and
restore hike source photos over rsync, keeping them out of git. Transfers
only photo files (images/, private/, root cover.*) — matching the .gitignore
rules — so the versioned text (index.svx, track.gpx, icon.svg) is untouched.
Config via REMOTE / HIKE_PHOTOS_DIR with deploy.sh-style defaults; supports
--dry-run and --delete. No build changes: build-hikes still reads local files
and falls back gracefully when a photo is absent.
2026-05-23 17:05:47 +02:00
Alexander 909b02049d chore(hikes): track Rheinau + Muttertagswanderung content
src/content was untracked. Start versioning these two hikes' writing
(index.svx, track.gpx, icon.svg) and add a .gitignore that keeps source
photos out (images/, private/, root cover.*) — they're large and get
re-encoded into static assets at build time. Includes the proofreading pass
on both texts (typos, grammar, gender/agreement). Other hikes stay untracked.
2026-05-23 16:20:33 +02:00
Alexander d4a8288ecf fix(hikes): link journey planner to the current SBB deep-link format
Replace the search.ch link with an sbb.ch deep link that respects the
searched date/time and departure-vs-arrival. The live SBB timetable (2026)
uses stops=<from>~<to> with each stop as `<label>_I<stationId>`, time with
an underscore (09_10), a short dep/arr token, and a `day` mirror of date —
not the older von/nach + colon/ARRIVAL form. Station IDs (didok) are
captured during resolution (transport.opendata.ch returns SBB's IDs);
fall back to label-only stops when an ID can't be resolved.
2026-05-23 16:13:38 +02:00
Alexander 4114b0109f feat(hikes): show elapsed-since-start time on prose photos
Each <HikeImage> overlays a "nach X" badge (elapsed from the first
timestamped track point), matching the photo strip's formatElapsed wording.
Renders only when the photo carries a timestamp; bottom-right, or top-right
when a caption is present so it never overlaps.
2026-05-23 16:13:26 +02:00
Alexander 169f8798f3 feat(hikes): explicit cover.jpg for the listing card
A `cover.*` image (jpg/jpeg/png/webp/heic/heif) in a hike's images/ dir
(or hike root) now always becomes the overview-card cover, overriding the
"first public route photo" heuristic. Unlike route photos it needs no
track.gpx waypoint, is always public, and is excluded from the photo strip;
alt text falls back heroAlt → title → "Titelbild". The shared responsive
encoder is extracted from processImage into encodeImageVariant so the cover
reuses it; its outputs join the orphan-cleanup keep-set.
2026-05-23 16:09:14 +02:00
Alexander 8f843833e0 fix(route-builder): use Swisstopo elevations for snapped routes
routeWaypoints passed BRouter's coarse, SRTM-based inline elevations
straight through, and the client only queried Swisstopo when a point
lacked an altitude — so snapped routes never hit the Swiss terrain model
and shipped a jagged profile that disagreed with the densified off-trail
path. Overwrite every routed point with enrichElevations (geo.admin.ch),
keeping the router's value only as a fallback where Swisstopo returns null
(hikes abroad). Disk-cached by coordinate list, so repeat snaps stay free.
2026-05-22 18:51:02 +02:00
Alexander 35872d731a feat(hikes): SBB-style public-transport journey planner for hike prose
Add JourneyPlanner.svelte: a "Von / Nach" connections widget to drop into a
hike's prose (e.g. with a fixed trailhead destination) so readers can plan
the trip there by public transport. Backed by the free Swiss transport API
(transport.opendata.ch — the same one sbb-tui uses).

- From/To fields: prefillable + per-field lockable (fromFixed / toFixed),
  swap when both editable, and "Aktueller Standort" via geolocation
  (coarse accuracy → fast on laptops; resolves to the nearest stop).
- Station typeahead on both fields via /locations?type=station, so routes
  start/end at canonical stops instead of fuzzy-geocoded points (which was
  yielding roundabout connections); plus resolve-on-search fallback. The
  dropdown grows flush from the field as a route-line spur (matching dots),
  typed text highlighted.
- Separate date + time controls: date defaults to the next weekend day
  (Sat on weekdays, Sun on a Saturday); time + departure/arrival target are
  author-settable props (time="08:00" target="arrival") and reader-editable.
- Results: per-leg transport-type icons (bus/train/tram/ship/cable) and an
  expandable full itinerary (stations, times, platforms, line + direction,
  coloured rails, walk connectors); link to the full search.ch timetable.

Add TimePicker.svelte: a shared pill time picker (HH:MM string, chevron
nudges, hour/minute dropdown) in the same language as DatePicker /
DateTimePicker.
2026-05-22 18:33:23 +02:00
Alexander 2347a02fcb feat(hikes): worldwide maps via a region-switching tile proxy
Add tile-proxy/: a small Rust (axum) service behind nginx that serves one
canonical XYZ scheme (/{karte,luftbild,dufour}/{z}/{x}/{y}) and, per tile,
picks the provider by geometry — swisstopo when the tile overlaps a
swisstopo-covered region (Switzerland or Liechtenstein, each simplified +
2 km buffer; tile-bbox ∩ polygon at every zoom), otherwise OpenTopoMap
(schematic) / Esri World Imagery (satellite), with an auto-fallback for
border 404s. Includes the region generator (gen-regions.mjs), a Makefile,
nginx caching-proxy + systemd examples, and a README. Listen address is
env-driven (TILE_PROXY_ADDR).

App side:
- New mapTiles.ts is the single source for the proxy URLs + combined
  attribution; HikeMap / HikesOverviewMap / EditMap fetch through
  maps.bocken.org instead of swisstopo directly, on-map attribution
  controls removed, preconnect + footer credits updated (swisstopo /
  OpenStreetMap+OpenTopoMap / Esri).
- Region-aware schematic max zoom (isSwissRegion helper): detail map caps
  at z17 abroad and hides the CH/LI-only Dufour layer; overview caps at
  z18 when a shown hike is abroad.
- Route-builder: add the satellite layer via the same bottom-right layer
  popover as the other maps.
2026-05-22 16:26:22 +02:00
Alexander 5540d37c72 feat(route-builder): clearer map cursors, route click tolerance, swisstopo credit
- Pointing-hand cursor over the map (click adds a waypoint); grab/grabbing
  only on the draggable waypoint pins. Fixes the cursor living on the
  Leaflet container element itself (the `.edit-map` div), not a descendant.
- Route polylines render on a canvas renderer with a hit `tolerance`, so a
  click near the line inserts a waypoint without a pixel-perfect hit.
- Hide the on-map attribution control and show the required "Kartendaten ©
  swisstopo" credit in the page footer, matching /hikes.
2026-05-22 14:26:44 +02:00
Alexander 6483c55fce feat(hikes): multi-day stages (separate GPX tracks, stage nav, builder)
Represent a multi-day hike as separate named GPX <trk> elements, one per
stage, while still treating the whole thing as one route on the overview.

GPX & build:
- gpx.ts: parseGpxStages (one stage per <trk>) + multi-track buildGpx.
- build-hikes.ts: per-stage stats with totals summed across stages so the
  overnight gaps (distance, time) and the altitude jump between stages are
  excluded; previewBreaks recorded where stages sit >1 km apart.
- types: HikeStage, manifest `stages?` and `previewBreaks?` (both optional —
  single-stage hikes are unchanged).

Detail page:
- HikeStageNav: a light itinerary-stepper switcher (numbered nodes, active
  glows in the accent) writing a shared stageStore.
- Selecting a stage scopes the metrics, elevation profile (x-window),
  map (highlight + zoom, dim the rest) and photo strip/markers; "Alle
  Etappen" shows the whole route.

Overview: live map and the prerendered static composite both break the
preview line across >1 km inter-stage transfers (previewBreaks).

Route builder:
- Mark any placed waypoint as a stage start (named) from the waypoint list
  or the detail panel; export assembles each stage independently into its
  own <trk>; import re-marks stage boundaries from a multi-track GPX.
2026-05-22 14:14:57 +02:00
Alexander 603240bf93 feat(route-builder): stats bar, waypoint detail panel, elevation refactor
Work-in-progress route-builder checkpoint:

- New RouteStatsBar and WaypointDetailPanel components.
- EditMap / ImageDropzone / WaypointTable / builderStore updates.
- Hoist the elevation gain/loss/range helpers out of build-hikes.ts into
  src/lib/hikes/elevation.ts so the builder and the build share one
  implementation.

Also bundled here (same file, couldn't be split cleanly): build-hikes.ts
now detects each hike's country at build time — 'CH' when a Swiss canton
matched, otherwise an OSM/Nominatim reverse-geocode — and writes it to the
manifest, feeding the new Kanton/Land filter.
2026-05-22 13:07:24 +02:00
Alexander 53695b8244 feat(hikes): in-season toggle + unified canton/country filter
Add two filters to the /hikes filter panel:

- "Nur Touren in der aktuellen Saison" toggle — keeps only hikes whose
  recommended season window covers the current month (year-wrap aware;
  hikes without a window count as year-round).
- "Kanton / Land" — a typeahead that abstracts the hike's area over the
  border: Swiss hikes group by canton (coat-of-arms), hikes abroad by
  country (flag). Generalised the tag typeahead into ChipTypeahead
  (optional icon + label mapping) and reused it for both tags and areas.

Supporting bits: countries.ts (ISO/name → flag), hikeArea.ts (the
canton-or-country resolver, namespaced so codes can't collide), prepared
flag SVGs for CH/DE/IT/AT/FR, and an optional `country` field on the hike
manifest type (populated by the build script; the app falls back to canton
for Swiss hikes until a rebuild).
2026-05-22 13:06:47 +02:00
Alexander 48d971c216 feat(hikes): hide on-map attribution control, add swisstopo credit
Drop Leaflet's bottom-right attribution control on both the overview and
detail maps for a cleaner frame. The swisstopo tile licence still requires
their credit, so keep it on the page: the detail page already shows it in
the meta footer, and the overview now gets a tiny "Kartendaten © swisstopo"
line at the bottom of the listing.
2026-05-22 12:42:37 +02:00
Alexander bb1d494c48 feat(hikes): forgiving map selection, photo lightbox, detail polish
Map interaction:
- Overview map: widen the canvas renderer hit-test (tolerance) so a route
  can be hovered/clicked from a comfortable margin instead of demanding a
  pixel-perfect click on the thin line.
- Detail map: drive the elevation cursor from a whole-map mousemove that
  snaps to the nearest track point within ~70 px (track cached in
  layer-point space, refreshed on zoom/move), instead of requiring the
  pointer to ride exactly on the trail. The hover pin now renders for
  map-sourced hovers too, and is recoloured to nord red as a distinct
  "you are here" marker. Trail polyline made non-interactive.

Detail page:
- Move the photo strip above the stats row and trim it (3:2 cards).
- Add a fullscreen lightbox: an expand button on each card opens the
  full-res image with prev/next, arrow keys, Esc, backdrop-close and a
  body-scroll lock; opening/stepping syncs the map + strip. The card's
  existing click (map-position sync) is preserved.
- Cap inline prose images at 680 px (centered) so they don't blow up to
  full width in the single-column layout on wider screens; the desktop
  two-column layout is unaffected.
2026-05-22 12:36:06 +02:00
Alexander 896e42f5d9 feat(hikes): redesign /hikes filter as a quiet command bar
Replace the flat always-open filter slab with a collapsed command bar:
a result/totals summary, removable active-filter chips, and a Filter
toggle (with active-count badge). The control panel expands in-flow
below the bar (slide), pushing the card grid down instead of overlaying
it, with breathing room added between the hero map and the bar.

- Range filters are now dual-thumb sliders (lower + upper bound) for
  distance, duration, ascent and descent, via a new RangeSlider
  component (pointer + keyboard, crossover hand-off). Shared bounds math
  lives in filterBounds.ts so slider extents and the page's default
  filter state can't drift.
- Difficulty selection renders the actual SAC trail-sign markers
  (T1 yellow Wegweiser, T2/T3 white-red-white, T4-T6 white-blue-white),
  matching the hike cards; selected signs light up in full colour while
  unselected ones are dimmed.
- Tag selection uses a typeahead (input + dropdown + removable chips),
  mirroring the recipe filter but themed with the semantic variables.
2026-05-22 12:10:18 +02:00
Alexander 7bede8cd64 feat(route-builder): SAC-red trail + refit map on image drop
Trail polyline now uses the same SAC white-red-white red as the /hikes
overview and detail pages so the live preview reads as the final
published track. Dropping geolocated images now reframes the edit map
to the new bounds via a shared `mapView.fitTick` signal — covers GPX
imports too if they ever wire it up.
2026-05-19 21:22:34 +02:00
Alexander 3b524e9c70 feat(route-builder): match dropped images to imported hash-only waypoints
After a GPX import the image-anchor waypoints carry just their
content hash (no thumbnail). Drop the source JPEGs into the same
image dropzone and they're now hashed and matched against existing
waypoints first — on a hit, the thumbnail + full-resolution cache
are patched onto the matched row instead of creating a duplicate
waypoint marker on the map.

A blue 'Bildvorschau ergänzt' status row + contextual hint at the
top of the dropzone ('N Bild-Wegpunkte warten auf eine Vorschau —
Originale hier ablegen') makes the round-trip workflow discoverable.

Existing fresh-upload behaviour (new image → new chronologically-
inserted waypoint with EXIF GPS) is unchanged for any image whose
hash isn't already known to the builder.
2026-05-19 17:36:38 +02:00
Alexander 59f40b9f05 feat(route-builder): import existing GPX (round-trip editing)
Lets the user re-load a previously-exported GPX and keep iterating
on the same route — add a waypoint, fix a turn, retag an image —
without rebuilding from scratch.

The exported GPX interleaves user-anchor waypoints with densified /
snapped intermediates in a single `<trkseg>`. The importer doesn't
try to perfectly round-trip "manual waypoint vs intermediate";
instead it recovers the *image* anchors by matching `<wpt>`
coordinates against the trkpt sequence (1e-5° tolerance, ≈1 m),
plus the start + end trkpts, and reconstructs routedSegments from
the trkpts between adjacent anchors. The intermediate geometry is
preserved verbatim — no re-routing, no second elevation pass.

Image waypoints carry their `imageHash` + `imageVisibility` across
the round-trip so the build script can still re-attach the source
JPEGs on the next publish. Visual previews from those hashes are
deferred to a follow-up — for now an image anchor renders as a
hash-only badge in the waypoint table.

Auto-snap is forced off after import so the freshly-loaded geometry
isn't immediately overwritten by a routing API call. UI: a "GPX
laden" link-style button next to the existing Reset, confirms
before replacing a non-empty draft.

The pure parsers (`parseGpx`, `parseGpxImageRefs`) move from
`$lib/server/gpx` to `$lib/gpx` so the browser-side importer can
use them; the server module re-exports for back-compat.
2026-05-19 17:29:34 +02:00
Alexander 7b7fbed472 fix(hikes): repair Swisstopo elevation API (LV95 + POST), add busy chip
Two upstream constraints were causing the densified route-builder
tracks to ship without `<ele>` on most points:

1. api3.geo.admin.ch's elevation services reject `sr=4326` outright
   (HTTP 400: "Please provide a valid number for the spatial
   reference system model: 21781, 2056"). Add an in-process
   WGS84→LV95 converter using Swisstopo's published approximation
   (≈1 m positional accuracy, well below the DTM grid resolution)
   and switch both `height` and `profile.json` to sr=2056.

2. profile.json GET silently 414s once the URL crosses ~8 KB. At
   our densified-track sizes a 200-coord chunk hit ~9.6 KB and got
   dropped — that's why only a handful of segments came back with
   elevations. Switch to POST + form-encoded body; chunk size can
   safely go to 500 coords.

UX: extend the busy state to cover the densify+elevate path
(previously only set during snap-to-route) and add a mode-agnostic
status chip in the header that pulses amber while the elevation
request is in flight. GPX download button is now disabled while
busy so the file can't be exported half-finished.
2026-05-19 11:01:23 +02:00
Alexander e3ccd96c7b feat(route-builder): densify + elevate off-trail segments
With "snap to route" off, every waypoint pair shipped as a 2-point
linear segment with no `<ele>` anywhere — the page only enriched
elevations inside the snap path. The resulting GPX had a flat
altitude profile and the build script's gain/loss/min/max metrics
came out all zero.

Two changes:

* New `densifyLinearSegments` (default 25 m, matches Swisstopo's
  coarsest DTM) walks every 2-point segment and seeds intermediate
  vertices along the great circle. Snapped segments (already many
  points from BRouter) are left alone.
* Page reactor now runs the same Swisstopo elevation enrichment in
  the autoSnap-off path, so the GPX carries per-trkpt `<ele>` even
  for fully manual / cross-country routes.

Elevation source unchanged: Swisstopo profile.json (COMB → DTM2 →
DTM25 fallback) is already the highest-resolution provider for our
Swiss-coverage hikes; no point swapping.

Also unifies the snap-path's inline enrichment call into the same
helper so there's one elevation code path instead of two.
2026-05-19 10:40:24 +02:00
Alexander a1aa722512 feat(hikes): use SAC-tier colours for the detail-page trail
Live and static heroes on the detail page were rendered in Nord red,
while the same trail on the /hikes overview map already used the
high-contrast SAC palette (orange/red/blue). The mismatch made the
detail trail look muted against the Pixelkarte; the overview's choice
also doubles as a navigational hint ("the orange trail you saw on
the map is this one").

Introduce $lib/data/sacColors as the single source of truth so
HikeMap, HikesOverviewMap, and the build-side static renderer all
pull the same palette. Bump HERO_RENDER_VERSION to v6 so stale
Nord-red static heroes get re-rendered on the next build.
2026-05-19 10:27:31 +02:00
Alexander 706dedbdc5 fix(hikes): sync tag filter to URL + re-fit overview map on filter change
Two bugs:

* Toggling a tag in the filter bar didn't update `?tag=` in the URL,
  so the page wasn't shareable / back-button-restorable past the
  initial deep-link state. Add a writer $effect that mirrors
  `filter.tags` into the URL via `replaceState` (no history churn).

* The overview map's polylines + camera were built once on mount and
  never refreshed when the `hikes` prop changed, so filtering left
  the map showing the full set and zoomed for it. Extract polyline
  rendering into a function called both on mount and from a
  prop-watching $effect; on change, smoothly fly to the new union.
2026-05-19 10:19:08 +02:00
Alexander 2a8721fde0 feat(hikes): clickable tag chips + tag filter on the overview
Detail-page tag chips become anchor links to `/hikes?tag=<name>`.
HikesFilterBar grows a tags fieldset (sorted by frequency, with the
hash prefix the chips use) so the user can keep narrowing from there.

Multi-tag filtering is OR — a hike matching any selected tag stays
visible. AND would shrink the listing fast given how few tags most
hikes carry; OR matches what "show me more like this" feels like.

The overview page reads `tag` query params on mount and pre-fills the
filter — supports repeated params (`?tag=winter&tag=easy`).
2026-05-19 10:13:26 +02:00
Alexander 3331536ddd feat(hikes): show tags + relegate GPX download to a quiet meta footer
Tag chips slot between the metric tiles and the elevation profile —
muted pills with a hash prefix so they read as framing context, not
another row of glance-info competing with the numbers.

The standalone "GPX herunterladen" pill (centred, primary-styled,
above the photo strip) moves into a small footer at the bottom of
the article, grouped with waypoint count, publish date, and the
required swisstopo attribution. GPX is a power-user export, not a
CTA — placing it with other ancillary metadata matches its weight.
2026-05-19 10:04:45 +02:00
Alexander d957c746d5 fix(hikes): decode URL-encoded slugs in dev image middleware (fix ü/ä/ö 404s) 2026-05-19 08:45:14 +02:00
Alexander cfdd58fb18 feat(hikes): inline cantonal Wappen next to region label
26 public-domain coats of arms fetched once from Wikimedia Commons
via scripts/download-cantons.ts and committed under static/cantons/.
$lib/data/cantons.ts maps Swisstopo's free-form name (German default,
French/Italian alternates for Romandie / Ticino) to the ISO code +
emblem URL.

Card shows an 18×22 emblem, detail page a 24×30 one — both with a
drop-shadow so they read against the dark hero gradient. Unknown
canton names fall back to plain text without the emblem.

The downloaded SVGs are written verbatim — earlier draft prepended a
provenance HTML comment but that breaks the leading `<?xml … ?>` and
browsers refuse to render the image. Provenance lives in the script's
CANTONS table instead.
2026-05-19 08:44:30 +02:00
Alexander 2c3886296c fix(hikes): square-ish SAC red/blue painted markers (44px → 28px) 2026-05-19 08:30:02 +02:00
Alexander c082da700d feat(hikes): phone-sized static hero variant for ≤560 px viewports
The wide static hero picks its zoom for a desktop-sized container
(fitWidth 1920), so on phones the bbox lands too zoomed-in: most of
the route falls outside the visible 400 CSS px band.

Build now emits a second pose per hero — rendered with fitWidth 400 /
fitHeight 480 onto a 1200² canvas — so the auto-fit zoom matches what
Leaflet picks at the same container size. Per-hike hero gains four
variants total (theme × viewport); overview hero gains two.

The page picks which `<img>` to show via a `max-width: 560px` media
query (no JS needed for the swap), and `matchMedia` decides which
pose to hand to Leaflet's first `setView` so the static→live cross-
fade aligns regardless of viewport.

Drive-by: replace the long-stale `hike.heroMapUrl` reference in the
detail page's track-loading fallback with `hike.heroMapUrlLight`.
2026-05-19 08:27:08 +02:00
Alexander fe08e06a02 feat(hikes): pre-rendered overview hero map with same handover pattern
Mirrors the per-hike detail-page hero on the /hikes index. Build emits one
WebP at the union bbox of every visible hike with each preview polyline
drawn in its SAC-tier colour; page renders it under the live Leaflet map
and fades it out once the first tile batch loads.

Tile fetcher now distinguishes HTTP 4xx ("intentionally blank — outside
Switzerland") from real network errors, so the larger overview canvas
that extends into DE/IT/FR doesn't trip the network-failure abort.
2026-05-19 08:18:23 +02:00
Alexander fd2d8a58d9 feat(hikes): pre-rendered static hero map with smooth Leaflet handover
Each hike now ships two SSR-friendly hero images (light + dark theme),
composited at build time from Swisstopo tiles plus an SVG overlay of the
trail polyline, start/end markers, and per-photo camera badges. The
detail page renders the right variant immediately at first paint, then
hands over to live Leaflet without visible jumps.

Renderer (scripts/staticHikeMap.ts):
- Parallel tile fetcher with on-disk cache (scripts/.cache/swisstopo-
  tiles/) for re-build idempotency.
- `computeStaticMapPose` picks the zoom + centre Leaflet's fitBounds
  would land on at a reference 1920x640 viewport, so the static frames
  the full route on every typical desktop hero.
- Canvas rendered at 3840x2400 — large enough to fully cover ultrawide
  / 4K displays at native pixel size, so `object-fit: none` keeps the
  trail pixel-aligned with Leaflet's tile pane.
- SVG overlay: trail in Nord red, start dot Nord green, end dot Nord
  red, Lucide `camera` icon inside each photo badge. Photo badge
  fill / border / icon-stroke colours are passed per theme so light and
  dark variants match the live `.hike-photo-marker .badge` styling
  exactly (Nord10/Nord8 fill, Nord6/Nord1 border, white/Nord0 icon
  stroke). Map tiles themselves are identical across themes — no naive
  invert (it mangles the Pixelkarte palette).
- Public photo markers only — private positions are filtered out so
  they don't leak in the SSR image.

Build wiring (scripts/build-hikes.ts):
- `processHero` renders both variants in parallel, hashes inputs per
  theme, skips on cache hit. Output filenames carry the content hash so
  changes invalidate cleanly via the existing orphan sweep.
- `HikeManifestEntry` gains `heroMapUrlLight`, `heroMapUrlDark`,
  `heroMapZoom`, `heroMapCenter`.

Detail page (src/routes/hikes/[slug]/+page.svelte):
- Reserves the hero box height up front (kills CLS).
- Renders both `<img>` tags; CSS picks the right one via `data-theme`
  with `prefers-color-scheme` as the fallback.
- `object-fit: none; object-position: center` so the image displays at
  native pixel size, perfectly aligned with Leaflet's tile rendering.
- `isolation: isolate` on the hero gives Leaflet's z-index:200+ panes
  a stacking context so they can't bleed over the sticky nav.

HikeMap (src/lib/components/hikes/HikeMap.svelte):
- New `initialCenter` / `initialZoom` props — when set, the map opens
  with `setView` at the static hero's pose instead of `fitBounds`.
- New `onReady` callback — fires after the post-fly-to-bounds tile
  batch finishes loading (or a 350 ms safety timeout), letting the
  detail page fade the static out onto fully-painted tiles instead of
  onto a brief grey gap.
- Sequence: render static -> Leaflet `setView` to match -> first tile
  load -> `flyToBounds(track)` to the natural fit -> wait for new
  tiles -> fade static out.
2026-05-18 23:38:24 +02:00
Alexander f3d16d5187 feat(hikes): route-builder, overview map + cards, SAC-coloured pipeline
Build pipeline (scripts/build-hikes.ts) parses per-hike GPX, encodes
images via sharp, reverse-geocodes the centroid against Swisstopo and
emits a typed manifest under src/lib/data/hikes.generated.ts (gitignored).
Track JSON + image binaries live outside /static; served in dev by a
small hike-images plugin in vite.config.ts, in prod by nginx (private/
images proxied through Node + X-Accel-Redirect for auth-gating).

/hikes overview: full-bleed Swisstopo hero map (HikesOverviewMap) sits
under the sticky nav, drawing one polyline per route coloured by SAC
tier (T1 yellow Wegweiser, T2/T3 white-red-white, T4-T6 white-blue-
white). Click navigates, hover thickens + tooltips. Layer toggle,
recenter, GPS controls mirror the detail map (minus images toggle).
Cards drop the trail SVG, gain a per-route icon + SAC marker
pictogram on the cover, altitude range, season label, and "Neu" badge
for recently-published hikes. Filter bar + totals strip recompute over
the currently-visible set.

/hikes/[slug]: hero map with elevation profile, photo strip with map
sync, scroll-position pin, GPX download, SAC marker stats + min/max
altitude + season.

Route-builder (/hikes/route-builder): client-side draft persisted to
localStorage, EXIF-driven image placement, snap-to-route via BRouter
(OSRM + linear fallback) and Swisstopo profile.json elevation
enrichment that handles degenerate same-coord segments via the height
endpoint.

Filter init switched from a script-time snapshot of data.hikes (which
sporadically returned a one-hike subset during dev hydration and
locked the page to that single hike) to a post-mount \$effect.

Content under src/content/hikes/ intentionally not included (WIP).
2026-05-18 21:13:00 +02:00
Alexander 928774084f fix(fitness): restore SSE mirror-finish without racing local summary
Reapplies the e87b8bd8 mirror-finish behaviour (saved session is
broadcast on Finish so other devices can render the completion overview)
behind a _finishingLocally guard. workout.finish() flips workout.active
synchronously, but completionData is only set after the awaited POST
resolves — without the guard, the redirect effect fired in that gap and
navigated away before the summary could render on desktop.
2026-05-12 17:40:41 +02:00
Alexander 8c09b0b2f4 Revert "fix(fitness): mirror finish overview to other devices via SSE"
This reverts commit e87b8bd864.
2026-05-12 17:30:02 +02:00
Alexander 5ac56db46c feat(fitness): auto-advance exercises with view transitions + audio cues
Last set of an exercise auto-advances to the next unfinished exercise.
View Transitions API animates the swap: vertical slide on the exercise
name and set-counter row, horizontal slide on the set table at >=900px.
Three Web Audio cues — per-set tick, exercise-complete two-note rise,
and workout-complete fanfare on the completion overview.
2026-05-12 17:25:54 +02:00
Alexander 5fd8027d3e feat(fitness): label finish button "FINISH EARLY" with unfinished sets
CI / update (push) Has been cancelled
Switches the active-workout finish button to "FINISH EARLY" /
"VORZEITIG BEENDEN" with an orange tint when any set in the workout is
still incomplete, so users can tell at a glance whether they're
wrapping up cleanly or cutting it short.
2026-05-10 14:56:12 +02:00
Alexander e87b8bd864 fix(fitness): mirror finish overview to other devices via SSE
Other devices were left on a blank active-workout page when one device
hit Finish. The finish broadcast now carries the saved session document,
and the receiving page builds the completion overview from it. If the
remote end-of-workout has no session payload (cancel, or the finishing
device couldn't post the session), receivers redirect to the workout
home instead of stranding on a blank page.
2026-05-10 14:42:50 +02:00
Alexander eeed31aaf4 fix(fitness): hoist rest timer above set table, persist across exercise switches
Rest timer was inlined as a row inside SetTable, tied to a specific set.
Switching to another exercise hid it from view. Now lives below the
exercise focus card, renders whenever a rest is active regardless of
focused exercise, and labels which set/exercise it belongs to when
looking at a different one.
2026-05-10 14:17:36 +02:00
Alexander e59e9679da fix(faith): align calendar view tab labels on timespan + shape
Year/Hills/Month mixed two axes. Now: Year · Ring, Year · Hills,
Month · Grid (with de + la translations).
2026-05-10 14:06:40 +02:00
Alexander 685f4cc892 fix(header): drop extra 12px gap when safe-area inset is present
CI / update (push) Has been cancelled
In PWA / Tauri shells with a notch or status bar, the header bar was
stacking 12px on top of the safe-area inset, leaving the bar far below
the system chrome. Use max(12px, inset + 4px) so the constant gap only
applies when there is no inset; otherwise the bar sits 4px below the
inset edge.
2026-05-10 13:00:25 +02:00
Alexander 60e651de72 feat(faith): add hills view of liturgical year as calendar tab
CI / update (push) Has been cancelled
Two-hill silhouette (Christmas + Easter peaks) rendered horizontally
with season fills, feast dots riding the curve, dividers between
adjacent same-color seasons, and chevron prev/next year navigation.
Breaks out of the page content width to span the viewport, scrolls
horizontally below 1300px with the selected/today feast auto-centered
on mount and viewport resize.
2026-05-10 12:48:28 +02:00
Alexander 98417046bc fix(fitness): fertile window no longer overlaps period bleed
CI / update (push) Has been cancelled
Floor fertile/peak windows at the prior period's end + 1 day so a
short cycle + long period combo can't predict peak fertility starting
during or right after bleeding. Future cycles also widen the outer
fertile range using observed shortest/longest cycle (Ogino-style),
keeping the peak band narrow around the mean ovulation estimate.
2026-05-10 10:46:14 +02:00
Alexander 244050fa75 feat(fitness): cache more fitness shells & show unsynced workouts on history
Precache:
- Service worker now precaches /fitness/workout(/active) shells (DE+EN)
  on install so a fresh device can log workouts offline without first
  visiting /fitness online.
- Layout-level precache adds /fitness/__data.json itself plus the
  static sub-routes nutrition/meals and check-in/body-parts (DE+EN).

Unsynced workouts on history:
- History page reads the offline outbox via getQueuedSessions on mount
  and merges queued sessions into the displayed list, sorted by
  startTime. Duration is computed locally so the Clock stat still
  shows.
- SessionCard gains an 'unsynced' prop: renders as a non-clickable
  div with an orange-accent border and a CloudOff badge labelled
  'Unsynced' / 'Nicht synchronisiert'.
- On window 'online', the page waits briefly for the layout's
  flushQueue to drain the outbox, then re-reads the queue and calls
  invalidateAll to swap unsynced placeholders for the now-saved
  server sessions.
2026-05-08 16:28:26 +02:00
Alexander 0814803fc7 fix(offline): IndexedDB fallback when API returns empty on /recipes & /season/[month]
These two pages only fell back to IndexedDB when navigator.onLine was
false. On mobile the device often reports online while the origin is
flaky (502 / slow cellular / cached shell with stale connectivity), so
the API call returned nothing and the pages rendered 0 recipes. Now
both also fall back to IndexedDB when the API attempt yields an empty
list, matching the pattern already used by [name]/+page.ts and
icon/[icon]/+page.ts.
2026-05-08 16:01:59 +02:00
Alexander eb2ffac536 fix(offline): fall back to cached shell on upstream 5xx
Service worker previously only fell back to cache when fetch threw
(network unreachable). A 502/503/504 from the origin returned
successfully with !response.ok, so the bad page was passed through to
the user. Now upstream 5xx is treated like a network failure: try
cached page, then offline-shell redirect for recipe routes, then the
styled offline page. 4xx still passes through unchanged.
2026-05-07 07:52:29 +02:00
201 changed files with 72128 additions and 417827 deletions
+41
View File
@@ -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[@]}"
+35
View File
@@ -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.*
-2
View File
@@ -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
+54 -4
View File
@@ -24,10 +24,18 @@ Order = impact. Font items + app.html preload intentionally skipped.
[x] BF graph (with trend line like weight graph) on /fitness/stats page. Emphasize relative changes, not absolute numbers in design (as we cannot trust those) (e.g., use start day of overview as 0% and then show +/- x % on the graph)
[x] 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.
+9 -3
View File
@@ -1,13 +1,14 @@
{
"name": "homepage",
"version": "1.67.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 && pnpm exec vite-node scripts/generate-error-quotes.ts",
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts && pnpm exec vite-node scripts/build-hikes.ts && pnpm exec vite-node scripts/build-private-images.ts",
"build": "vite build",
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts",
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts && UV_THREADPOOL_SIZE=12 pnpm exec vite-node scripts/precompress.ts",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -23,12 +24,15 @@
"test:e2e:docker:run": "docker run --rm --network host -v $(pwd):/app -w /app -e CI=true mcr.microsoft.com/playwright:v1.56.1-noble /bin/bash -c 'npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build && pnpm exec playwright test --project=chromium'",
"deploy": "bash scripts/deploy.sh",
"deploy:dry": "bash scripts/deploy.sh --dry-run",
"photos:push": "bash scripts/hike-photos.sh push",
"photos:pull": "bash scripts/hike-photos.sh pull",
"tauri": "tauri"
},
"packageManager": "pnpm@9.0.0",
"devDependencies": {
"@playwright/test": "1.56.1",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/enhanced-img": "^0.10.4",
"@sveltejs/kit": "^2.56.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2.10.1",
@@ -40,6 +44,7 @@
"@vitest/ui": "^4.1.2",
"bwip-js": "^4.10.1",
"jsdom": "^27.2.0",
"mdsvex": "^0.12.7",
"svelte": "^5.55.1",
"svelte-check": "^4.4.6",
"tslib": "^2.8.1",
@@ -59,6 +64,7 @@
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"exifr": "^7.1.3",
"file-type": "^19.0.0",
"leaflet": "^1.9.4",
"mongoose": "^9.4.1",
+136
View File
@@ -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
File diff suppressed because it is too large Load Diff
+198
View File
@@ -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);
});
+50 -2
View File
@@ -17,6 +17,18 @@ REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}"
SERVICE="${SERVICE:-homepage.service}"
ERROR_PAGES_DIR="${ERROR_PAGES_DIR:-/var/www/errors}"
ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}"
# Hike images live outside the Node app: nginx serves /hikes/<slug>/images/
# directly from disk and gates /hikes/<slug>/private/ through Node via
# X-Accel-Redirect. The build pipeline writes them to ./hikes-assets/ and we
# rsync that tree to the path nginx serves from.
HIKES_ASSETS_DIR="${HIKES_ASSETS_DIR:-/var/www/static/hikes}"
HIKES_ASSETS_OWNER="${HIKES_ASSETS_OWNER:-http:http}"
# Private (auth-gated) images for <Image private>. Built into ./private-assets/
# and served by nginx ONLY via an `internal` location reached through the
# endpoint's X-Accel-Redirect — add this once to the server's nginx config:
# location /protected-images/ { internal; alias /var/www/static/private-images/; }
PRIVATE_ASSETS_DIR="${PRIVATE_ASSETS_DIR:-/var/www/static/private-images}"
PRIVATE_ASSETS_OWNER="${PRIVATE_ASSETS_OWNER:-http:http}"
DRY=""
if [[ "${1:-}" == "--dry-run" ]]; then
@@ -39,7 +51,25 @@ echo " node $local_node (match)"
echo ":: Installing deps (frozen lockfile)"
pnpm install --frozen-lockfile
echo ":: Building"
# Build against production env, NOT the dev .env. SvelteKit's
# `$env/static/private` (IMAGE_DIR, DB creds, …) is inlined at BUILD time, so a
# build that picks up the dev .env ships dev values to prod — e.g. the relative
# IMAGE_DIR="./imgs/" that resolves under the service's dist/ cwd instead of the
# real served image dir. We export .env_prod into the environment; real env vars
# take precedence over .env files in Vite/SvelteKit's env loading, so this wins
# for the whole `pnpm build` lifecycle (prebuild vite-node scripts + build).
PROD_ENV="${PROD_ENV:-.env_prod}"
if [[ ! -f "$PROD_ENV" ]]; then
echo "!! $PROD_ENV not found in $(pwd) — refusing to build with the dev .env."
echo " Create $PROD_ENV with production values (IMAGE_DIR must be an"
echo " ABSOLUTE path to the served recipe-image dir, DB creds, etc.)."
exit 1
fi
echo ":: Building (env from $PROD_ENV)"
set -a
# shellcheck source=/dev/null
source "$PROD_ENV"
set +a
pnpm build
if [[ ! -d build ]]; then
@@ -74,13 +104,31 @@ ssh "$REMOTE" "mkdir -p $ERROR_PAGES_DIR"
rsync -az --delete $DRY --info=progress2 \
build/client/errors/ "$REMOTE:$ERROR_PAGES_DIR/"
if [[ -d hikes-assets ]]; then
echo ":: Syncing hikes-assets/ → $REMOTE:$HIKES_ASSETS_DIR/"
ssh "$REMOTE" "mkdir -p $HIKES_ASSETS_DIR"
rsync -az --delete $DRY --info=progress2 \
hikes-assets/ "$REMOTE:$HIKES_ASSETS_DIR/"
else
echo ":: No hikes-assets/ dir — skipping nginx-served hike images sync"
fi
if [[ -d private-assets ]]; then
echo ":: Syncing private-assets/ → $REMOTE:$PRIVATE_ASSETS_DIR/"
ssh "$REMOTE" "mkdir -p $PRIVATE_ASSETS_DIR"
rsync -az --delete $DRY --info=progress2 \
private-assets/ "$REMOTE:$PRIVATE_ASSETS_DIR/"
else
echo ":: No private-assets/ dir — skipping auth-gated image sync"
fi
if [[ -n "$DRY" ]]; then
echo ":: Dry run complete — no service restart"
exit 0
fi
echo ":: Fixing ownership on server"
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR"
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR && if [[ -d $HIKES_ASSETS_DIR ]]; then chown -R $HIKES_ASSETS_OWNER $HIKES_ASSETS_DIR; fi && if [[ -d $PRIVATE_ASSETS_DIR ]]; then chown -R $PRIVATE_ASSETS_OWNER $PRIVATE_ASSETS_DIR; fi"
echo ":: Restarting $SERVICE"
ssh "$REMOTE" "systemctl restart $SERVICE"
+126
View File
@@ -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);
});
+235
View File
@@ -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);
});
+77
View File
@@ -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"
+174
View File
@@ -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; // 14 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`
);
+424
View File
@@ -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 wmts10wmts100. 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 ~620 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;
}
+93
View File
@@ -464,6 +464,99 @@ a:focus-visible {
animation: none;
}
/* ============================================
HIKES TRANSITIONS
Cards + filter fly in/out vertically, clicked card morphs into the hero
map (cross-fade between thumbnail and map), and the whole below-map panel
(an opaque sheet) slides up from the bottom. Page chrome under the hero
cross-fades so nothing snaps in at transition end. Lives in app.css (not
the page component) so the rules are still loaded on the OLD side of a
nav AWAY from /hikes.
============================================ */
@keyframes hikes-fly-up {
from { transform: translateY(100vh); }
to { transform: translateY(0); }
}
@keyframes hikes-fly-down {
from { transform: translateY(0); }
to { transform: translateY(100vh); }
}
@keyframes hikes-root-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes hikes-root-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Only-child hike-fly-in pseudos (unpaired cards/filter on enter or exit):
* kill UA's default fade, switch blend mode so the custom fly animation
* shows clean motion against the rest of the page. */
::view-transition-old(.hike-fly-in):only-child,
::view-transition-new(.hike-fly-in):only-child {
animation: none;
mix-blend-mode: normal;
}
/* Paired (card ↔ hero): keep UA cross-fade so the card thumbnail dissolves
* into the hero map — otherwise the new image would just cover the old one
* and the thumbnail would vanish silently at t=0. Stretch the duration to
* match the group so the fade ends exactly when the morph does. */
::view-transition-old(.hike-fly-in):not(:only-child),
::view-transition-new(.hike-fly-in):not(:only-child) {
animation-duration: 550ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Group (the morphing bbox) timing. */
::view-transition-group(.hike-fly-in) {
animation-duration: 550ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Cards + filter rise from below the viewport on enter. */
html.vt-enter-hikes::view-transition-new(.hike-fly-in):only-child {
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
/* Cards + filter drop off the bottom on exit. */
html.vt-exit-hikes::view-transition-old(.hike-fly-in):only-child {
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
}
/* Everything below the hero map on a detail page — stage nav, photo strip,
* metrics, tags, elevation chart, scroll area, meta footer — slides up from
* the bottom on enter and back down on any exit, as one panel. The wrapper
* carries `view-transition-name: hike-below-map` and an opaque background, so
* the whole sheet (background included) moves; the hero map morphs separately
* above, and the rest of the page chrome cross-fades via the root-pseudo rule. */
html.vt-enter-hike-detail::view-transition-new(hike-below-map):only-child {
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
html.vt-enter-hikes::view-transition-old(hike-below-map):only-child,
html.vt-exit-hike-detail::view-transition-old(hike-below-map):only-child {
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
}
/* Cross-fade the rest of the page (root pseudo) during hike transitions so
* the destination's chrome — metrics + content + footer on the detail page,
* overview hero + credit on the index — phases in instead of snapping in
* at the end of the morph. Overrides the global rule above; scope keeps
* other routes' transitions on their existing instant-swap behavior. */
html.vt-enter-hike-detail::view-transition-old(root),
html.vt-enter-hikes::view-transition-old(root),
html.vt-exit-hikes::view-transition-old(root),
html.vt-exit-hike-detail::view-transition-old(root) {
animation: hikes-root-fade-out 450ms ease-out both;
}
html.vt-enter-hike-detail::view-transition-new(root),
html.vt-enter-hikes::view-transition-new(root),
html.vt-exit-hikes::view-transition-new(root),
html.vt-exit-hike-detail::view-transition-new(root) {
animation: hikes-root-fade-in 450ms ease-out both;
}
/* ============================================
RECIPE GRID
Responsive card grid used across recipe pages
@@ -0,0 +1,102 @@
---
title: Flims Gletschermühlen
date: 2024-07-14
author: Alexander
difficulty: T2
tags: [Graubünden, Flims, Sommer]
seasons: 5-8
summary:
heroAlt:
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
import Private from '$lib/components/Private.svelte';
</script>
## Anreise
Start bei Bargis. Anreise am besten via Bus von Flims.
<JourneyPlanner from="<current location>" to="Fidaz, Bargis" toFixed time="07:00" target="arrival"/>
<HikeImage idx={0} />
<HikeImage idx={1} />
<HikeImage idx={2} />
<HikeImage idx={3} />
<HikeImage idx={4} />
<HikeImage idx={5} />
<HikeImage idx={6} />
<HikeImage idx={7} />
<HikeImage idx={8} />
<HikeImage idx={9} />
<HikeImage idx={10} />
<HikeImage idx={11} />
<HikeImage idx={12} />
<HikeImage idx={13} />
<HikeImage idx={14} />
<HikeImage idx={15} />
<HikeImage idx={16} />
<HikeImage idx={17} />
<HikeImage idx={18} />
<HikeImage idx={19} />
<HikeImage idx={20} />
<HikeImage idx={21} />
<HikeImage idx={22} />
<HikeImage idx={23} />
<HikeImage idx={24} />
<HikeImage idx={25} />
<HikeImage idx={26} />
<HikeImage idx={27} />
<HikeImage idx={28} />
<HikeImage idx={29} />
<HikeImage idx={30} />
<HikeImage idx={31} />
<HikeImage idx={32} />
<HikeImage idx={33} />
<HikeImage idx={34} />
<HikeImage idx={35} />
<HikeImage idx={36} />
<HikeImage idx={37} />
<HikeImage idx={38} />
<HikeImage idx={39} />
<HikeImage idx={40} />
<HikeImage idx={41} />
<HikeImage idx={42} />
<HikeImage idx={43} />
<HikeImage idx={44} />
<HikeImage idx={45} />
<HikeImage idx={46} />
<HikeImage idx={47} />
<HikeImage idx={48} />
<HikeImage idx={49} />
<HikeImage idx={50} />
<HikeImage idx={51} />
<HikeImage idx={52} />
<HikeImage idx={53} />
<HikeImage idx={54} />
<HikeImage idx={55} />
<HikeImage idx={56} />
<HikeImage idx={57} />
<HikeImage idx={58} />
<HikeImage idx={59} />
<HikeImage idx={60} />
<HikeImage idx={61} />
<HikeImage idx={62} />
<HikeImage idx={63} />
<HikeImage idx={64} />
<HikeImage idx={65} />
<HikeImage idx={66} />
<HikeImage idx={67} />
<HikeImage idx={68} />
<HikeImage idx={69} />
<HikeImage idx={70} />
<HikeImage idx={71} />
<HikeImage idx={72} />
<HikeImage idx={73} />
<HikeImage idx={74} />
<HikeImage idx={75} />
## Abreise
Via Bus oder Auto wieder nach Hause. Wenn man nicht abgeholt wird wie wir, muss man noch etwas weiter laufen bis nach Trin.
<JourneyPlanner from="Trin, Quadris" fromFixed to="<current location>" time="15:30" target="departure"/>
File diff suppressed because it is too large Load Diff
@@ -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" />
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

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" />
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

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" />
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

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"/>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 37 KiB

@@ -0,0 +1,73 @@
---
title: Klausenpasswanderung
date: 2025-08-17
author: Alexander
difficulty: T2
tags: [Zentralschweiz, Panorama, family, Spätsommer]
summary: Eine schöne Aussichtstour durch die Zentralschweiz... wenn das Wetter hält
heroAlt:
seasons: 5-8
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
</script>
## Übersicht
Eine wunderschöne Panoramawanderung im Zentralschweizer Gebirge.
Anspruch nicht sehr hoch, da grösstenteils flach oder gemütlich abwärts.
Bei unserem Besuch hat das Wetter leider nicht ganz mitgespielt, weswegen grosse Teile voller Nebel waren.
## Anfahrt
Anreise via Altdorf, Uri und dann mit dem Bus auf die Klausen Passhöhe.
<JourneyPlanner from="<current location>" to="Klausen Passhöhe" toFixed time="09:00" target="arrival" />
Die Wanderung hat wunderschön angefangen mit einer weiten Aussicht über das Tal.
<HikeImage idx={0} />
Wir haben auch direkt einen Bergsalamander getroffen.
<HikeImage idx={1} />
<HikeImage idx={2} />
<HikeImage idx={3} />
<HikeImage idx={4} />
<HikeImage idx={5} />
<HikeImage idx={6} />
Kurz bevor der Nebel aufkam hatten wir noch einen wunderschönen Ausblick auf den Gross Windgällen (rechts) und Gross Ruchen (links)
<HikeImage idx={7} />
<HikeImage idx={8} />
<HikeImage idx={9} />
<HikeImage idx={10} />
Leider verfolgte uns Nach 1.5 Stunden der Nebel, was die weitere Aussicht erschwerte.
Nichtsdestotroz hat die Wanderun eine schönen Wanderweg bereitgestellt.
<HikeImage idx={11} />
<HikeImage idx={12} />
<HikeImage idx={13} />
<HikeImage idx={14} />
<HikeImage idx={15} />
<HikeImage idx={16} />
Bei Mättental gab es einige Möglichkeiten Bergkäse direkt ab Hof zu kaufen. Ein nettes Mitbringsel von der Wanderung.
<HikeImage idx={17} />
Nach ca. 4 Stunden war der Nebel grösstenteils vorbei und wir konnten die schöne Alpenlandschaft geniessen.
<HikeImage idx={18} />
<HikeImage idx={19} />
<HikeImage idx={20} />
<HikeImage idx={21} />
<HikeImage idx={22} />
<HikeImage idx={23} />
<HikeImage idx={24} />
<HikeImage idx={25} />
<HikeImage idx={26} />
<HikeImage idx={27} />
<HikeImage idx={28} />
Ankunft bei der Bergbahn Eggberge nach ca. 5 Stunden.
<HikeImage idx={29} />
## Abreise
Via Bergbahn und Bus von Eggberge nach Altdorf. Dort via Zug nach Hause.
<JourneyPlanner from="Bergbahn Eggberge" to="<current location>" fromFixed time="14:00" target="departure" />
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,105 @@
---
title: Siebengipfelwanderung
date: 2025-08-11
author: Alexander
difficulty: T2
tags: [St. Gallen, Walensee, Almwanderung]
seasons: 5-8
summary:
heroAlt:
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
</script>
### Anreise
Anreise an Bahnhof Unterterzen, dann mit Gondeln hoch nach Flumserberg und via Gondel von Flumserberg auf den Maschgenkamm.
<JourneyPlanner from="<current location>" to="Maschgenkamm" toFixed time="08:30" target="arrival"/>
<HikeImage idx={0} />
<HikeImage idx={1} />
<HikeImage idx={2} />
## Gipfel 1: Ziger
<HikeImage idx={3} />
<HikeImage idx={4} />
<HikeImage idx={5} />
<HikeImage idx={6} />
<HikeImage idx={7} />
<HikeImage idx={8} />
## Gipfel 2: Leist
<HikeImage idx={9} />
<HikeImage idx={10} />
## Gipfel 3: Rainissalts
<HikeImage idx={11} />
<HikeImage idx={12} />
<HikeImage idx={13} />
<HikeImage idx={14} />
<HikeImage idx={15} />
<HikeImage idx={16} />
## Gipfel 4: Gulmen
<HikeImage idx={17} />
<HikeImage idx={18} />
<HikeImage idx={19} />
<HikeImage idx={20} />
<HikeImage idx={21} />
<HikeImage idx={22} />
<HikeImage idx={23} />
<HikeImage idx={24} />
<HikeImage idx={25} />
<HikeImage idx={26} />
<HikeImage idx={27} />
<HikeImage idx={28} />
<HikeImage idx={29} />
<HikeImage idx={30} />
<HikeImage idx={31} />
<HikeImage idx={32} />
<HikeImage idx={33} />
<HikeImage idx={34} />
<HikeImage idx={35} />
<HikeImage idx={36} />
<HikeImage idx={37} />
## Gipfel 5: Cuncels
<HikeImage idx={38} />
<HikeImage idx={39} />
<HikeImage idx={40} />
<HikeImage idx={41} />
## Gipfel 6: Chli Güslen
<HikeImage idx={42} />
<HikeImage idx={43} />
<HikeImage idx={44} />
## Gipfel 7: Gross Güslen
<HikeImage idx={45} />
<HikeImage idx={46} />
<HikeImage idx={47} />
<HikeImage idx={48} />
<HikeImage idx={49} />
<HikeImage idx={50} />
<HikeImage idx={51} />
<HikeImage idx={52} />
<HikeImage idx={53} />
<HikeImage idx={54} />
<HikeImage src="PXL_20240414_135254651.jpg" />
## Heimreise
Vom Flumserberg mit der Gondel wieder runter nach Unterterzen. Von Unterterzen via Zug nach Hause.
<JourneyPlanner fromFixed from="Flumserberg Tannenboden" to="<current location>" time="14:00" target="departure"/>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,33 @@
---
title: Spaziergang Uetliberg
date: 2025-08-07
author: Alexander
difficulty: T1
tags: [Zürich, Spaziergang, Sommer, mittel]
seasons: 4-9
summary:
heroAlt:
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
</script>
## Anreise
Start direkt von der Tramstation Triemli.
<JourneyPlanner from="<current location>" to="Zürich, Triemli" toFixed time="10:00" target="arrival"/>
Der erste Anstieg ist recht intensiv.
<HikeImage idx={0} />
<HikeImage idx={1} />
Danach geht es gemütlich dem Grat entlang. Ein entspannter Spaziergang bei schönem Wetter.
<HikeImage idx={2} />
<HikeImage idx={3} />
## Heimreise
Via Bus von Albispasshöhe nach Thalwil, dann nach Hause via Zug.
<JourneyPlanner fromFixed from="Langnau a.A., Albispasshöhe" to="<current location>" time="15:00" target="departure"/>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,151 @@
---
title: Muttertagswanderung Herisau Teufen
date: 2026-05-09
author: Alexander
difficulty: T2
tags: [appenzell, leicht, hügellandschaft, weiden]
seasons: 4-9
summary: Eine angenehme Wanderung über die Hügellandschaft von Appenzell
heroAlt:
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
</script>
### Übersicht
Eine nette Wanderung durch die hübsche Hügellandschaft des Appenzells. Man wandert entlang Feldwegen, Schotterstrassen, Teerstrassen und hier und da kurze Abschnitte auf Waldwegen.
Auf dem Weg durchquert man zwei kleine Schluchten mit Flüssen.
Ein netter Spaziergang, der keine besonderen Konditionsanforderungen über ein generelles Fitnessniveau hinaus stellt.
### Anreise
Die Wanderung startet direkt am Bahnhof Herisau und ist somit leicht erreichbar.
<JourneyPlanner from="<current location>" to="Herisau" toFixed time="10:00" target="arrival"/>
### Start
Schon schnell wurde klar, dass es ein wunderschöner Tag wird die grünenden Weiden des Appenzells zu erkunden.
<HikeImage idx={0} />
<HikeImage idx={1} />
<HikeImage idx={2} />
<HikeImage idx={3} />
<HikeImage idx={4} />
<HikeImage idx={5} />
### Schlucht Nummer Eins
Nach circa einer Stunde sieht man prominent den Kirchturm von Hundwil vor sich.
<HikeImage idx={6} />
Davor geht es noch via der Alten Tobelbrücke über die Urnäsch.
Die überdachte Brücke trägt an ihren Dachbalken christliche Zitate.
<HikeImage idx={7} />
<HikeImage idx={8} />
<HikeImage idx={9} />
Die Urnäsch ist ein idealer Punkt für eine kurze erste Verpflegungspause.
<HikeImage idx={10} />
<HikeImage idx={11} />
<HikeImage idx={12} />
### Hundwil
Nach Schlucht Nummer Eins ist man bereits recht schnell in Hundwil und kann die wunderschöne Bauernlandschaft geniessen.
<HikeImage idx={13} />
<HikeImage idx={14} />
<HikeImage idx={15} />
<HikeImage idx={16} />
Nach 2:30 Stunden geht der Wanderweg direkt geradeaus über eine Wiese, während der Feldweg rechts abbiegt. Hier aufpassen, der Karte zu folgen.
<HikeImage idx={17} />
<HikeImage idx={18} />
<HikeImage idx={19} />
<HikeImage idx={20} />
<HikeImage idx={21} />
### Appenzeller Volkskundemuseum und Appenzeller Schaukäserei
In Stein angekommen, lohnt sich ein kleiner Abstecher in das Appenzeller Volkskundemuseum. Von Appenzeller Traditionen bis hin zu modernerer Kunst gibt es eine grosse Bandbreite an Appenzeller Kunst und Kultur.
Im Keller gibt es eine grosse Ausstellung zur traditionellen Stickkunst des Appenzells sowie deren Industrialisierung und Automatisierung.
#### Das Berg-Häämetli fressende Ungeheuer
<HikeImage idx={22} />
Von den modernen Bildern hat mir besonders obiges Gemälde des Künstlers Willy Künzler gefallen.
Wem die Botschaft der Politikerspinne, welche Hof (die im Appenzell üblichen Höfe werden auch (Berg)-<q>Häämetli</q> genannt) und Tier frisst und Golfplätze baut, noch zu unklar ist, dem hat Künzler auf der Seite des Bildes noch eine Hilfseinschrift hinterlassen:
> Wir wollen Bergbauern bleiben.
> Nicht Folklore- und Schau-Bauern sein.
> Nicht Golf-Handlanger werden.
Ein kurzer 30- bis 45-minütiger Abstecher in das Museum ist zu empfehlen.
Wem das zu langweilig ist, dem kann die Appenzeller Schaukäserei direkt nebenan eventuell gefallen oder als Ort der Stärkung dienen.
<HikeImage idx={23} />
Stein hat auch sonst überzeugt: Es zeigt sich, dass man auch neu bauen kann, ohne <q>markant eckig</q> sein zu müssen. Ein schönes Häämetli, das sehr gut ins Appenzell passt.
<HikeImage idx={24} />
<HikeImage idx={25} />
Auch alt kann überzeugen, mit schöner Obstwiese, verwittertem Holz und viel Detail im Holz.
### Schlucht Nummer Zwei
Danach geht es runter in Schlucht Nummer Zwei. Über Weide und Wald gelangt man dorthin, wo der Rotbach in die Sitter fliesst.
<HikeImage idx={26} />
<HikeImage idx={27} />
<HikeImage idx={28} />
<HikeImage idx={29} />
<HikeImage idx={30} />
<HikeImage idx={31} />
<HikeImage idx={32} />
<HikeImage idx={33} />
### Ehemaliges Kloster Wonnenstein
Nach Schlucht Nummer Zwei und einem Gefängnis kommt man an einem ehemaligen Nonnenkloster vorbei.
Das Kloster wurde erst 2021 wegen fehlendem Nachwuchs aufgelöst.
Man fragt sich vielleicht, wie ein katholisches Kloster ins protestantische Appenzell <em>Ausserrhoden</em> passt — die Halbkantone sind ja historisch aus konfessionellen Gründen gespalten.
Darüber gibt das Kloster via Infotafeln Auskunft:
Zwar war lange Zeit der Status des Klosters unklar, man hat sich jedoch einige Jahrzehnte nach den ursprünglichen Protestantenaufständen dazu einigen können, dass alles innerhalb der Klostermauern offiziell zu Appenzell <em>Innerrhoden</em> gehört. Somit ist das Kloster eine winzige Enklave Appenzell Innerrhodens innerhalb Appenzell Ausserrhoden.
<HikeImage idx={34} />
Die Klosterkirche selbst ist erst vor kurzem restauriert worden, mit einem neuen Altar für <i>ad populum</i> Messen.
Die drei Altäre dahinter haben alle einen Fokus auf die heilige Familie, ist die Kirche doch der heiligen Maria geweiht.
Die Marienfigur des linken Altars könnte Schweizer Katholiken bekannt vorkommen.
Es handelt sich hierbei um eine Replika der schwarzen Madonna aus Einsiedeln.
Dementsprechend ist die Replika auch nach der fehlgeschlagenen Restauration der Einsiedler Madonna angefertigt worden, ist diese doch ursprünglich farbig bemalt gewesen.
Der letzte Abschnitt vor Teufen verläuft teils neben der Autobahn, was nicht optimal ist. Gott sei Dank ist das Ganze aber nach 15 Minuten vorbei.
<HikeImage idx={35} />
## Ankunft in Teufen
Teufen erreicht man über den treffend genannten Ort <q>Einsamkeit</q>, wo ein einzelnes wunderschönes Bauernhaus einen begrüsst.
<HikeImage idx={36} />
<HikeImage idx={37} />
<HikeImage idx={38} />
## Heimreise
Von Teufen fährt halbstündlich ein Zug nach St. Gallen. Von dort die jeweilige Verbindung nach Hause.
<JourneyPlanner from="Teufen AR" to="<current location>" fromFixed time="16:30" target="departure"/>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
---
title: Einsiedeln Spital Unteriberg
date: 2026-03-07
author: Alexander
difficulty: T2
tags: [einsiedeln, schwyz, sommer, mittel]
seasons: 4-9
summary: Eine schnelle Gipfelwanderung in den Schwyzer Bergen
heroAlt: Blick aus dem Sihltal Richtung Unteriberg
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
</script>
## Übersicht
Vom Einsiedler Sihlsee geht es über ein paar Almen auf den Spitalgipfel.
Man hat eine schöne Aussicht auf das Schweizer Alpenmassiv, den grossen und kleinen Mythen Richtung Westen und natürich den Sihlsee hinter sich.
Eine nette Wanderung welche man doch frühstens im Mai begehen sollte. März war definitiv zu früh und es lag noch in Teilen knietief Schnee, nicht nur an Nordhängen.
## Anreise
Anreise nach Einsiedeln. Dann 10 Minuten mit Buslinie 555 nach Gross, Ebenau.
<JourneyPlanner from="<current location>" to="Gross, Ebenau" toFixed target="arrival" time="10:00" />
## Erster Anstieg
Von Gross ging es direkt los mit dem ersten richtigen Anstieg. Eine gute Aufwärmung für die restliche Wanderung.
<HikeImage idx={0} />
<HikeImage idx={1} />
<HikeImage idx={2} />
Früh Im Jahr sieht man schöne Almen, wenn auch etwas zu viel Schnee für den guten Geschmack.
<HikeImage idx={3} />
<HikeImage idx={4} />
### Erster Hügel
Erster Hügel ist erklummen. Jetzt geht die Route für ein zwei Kilometer recht flach der Almen entlang.
Derweil hat man einen wunderschöne Aussicht auf das schweizer Alpenmassiv und den Sihlsee.
## Zweiter Hügel
Jetzt geht es durch einen Wald zum zweiten Hügel, dem Spital. Die Bäume stehen hier und da etwas im Weg, es ist aber keine grössere Kraxelei um hoch zu kommen.
<HikeImage idx={5} />
<HikeImage idx={6} />
<HikeImage idx={7} />
<HikeImage idx={8} />
<HikeImage idx={9} />
<HikeImage idx={10} />
<HikeImage idx={11} />
<HikeImage idx={12} />
<HikeImage idx={13} />
<HikeImage idx={14} />
<HikeImage idx={15} />
<HikeImage idx={16} />
Jetzt zum schlimmsten Abschnitt, zumindest wenn man zu früh im Jahr unterwegs ist: der letzte Hügel auf den Spital hoch hat noch bis tief ins Jahr Schnee und wer nicht sehr früh am Morgen dort ist droht immer wieder einzubrechen.
<HikeImage idx={17} />
<HikeImage idx={18} />
## Spital
Am Spital angekommen lohnt sich eine Pause und den Ausblick zu geniessen.
<HikeImage idx={19} />
<HikeImage idx={20} />
<HikeImage idx={21} />
<HikeImage idx={22} />
## Abstieg
Der Abstieg nach Unteriberg ist recht schnell, jedoch auch nicht schneefrei bis in den Mai.
<HikeImage idx={23} />
<HikeImage idx={24} />
## Heimreise
Angekommen in Unteriberg — Zeit für eine Pause vor der Rückfahrt.
<HikeImage idx={25} />
Von Unteriberg per Bus zurück nach Einsiedeln (Linie 555 oder 556) und weiter mit der
S-Bahn ab Einsiedeln.
<JourneyPlanner from="Unteriberg, Guggelstrasse" to="<current location>" fromFixed target=departure time="14:30" />
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
---
title: Wanderung auf den Morgartenberg
date: 2026-04-19
author: Alexander
difficulty: T2
tags: [Schwyz, Zentralschweiz, kurz, leicht]
seasons: 4-9
summary:
heroAlt:
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
</script>
## Anreise
Start in Biberegg.
<JourneyPlanner from="<current location>" to="Biberegg" toFixed time="10:00" target="arrival"/>
<HikeImage idx={0} />
<HikeImage idx={1} />
<HikeImage idx={2} />
<HikeImage idx={3} />
<HikeImage idx={4} />
<HikeImage idx={5} />
<HikeImage idx={6} />
<HikeImage idx={7} />
<HikeImage idx={8} />
<HikeImage idx={9} />
<HikeImage idx={10} />
<HikeImage idx={11} />
<HikeImage idx={12} />
<HikeImage idx={13} />
<HikeImage idx={14} />
<HikeImage idx={15} />
<HikeImage idx={16} />
<HikeImage idx={17} />
<HikeImage idx={18} />
<HikeImage src="PXL_20260419_090639481.jpg" private />
<HikeImage src="PXL_20260419_090657549.jpg" private />
## Heimreise
Rückreise ab Sattel, Schornen.
<JourneyPlanner fromFixed from="Sattel, Schornen" to="<current location>" time="12:00" target="departure"/>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
---
title: Wallfahrt Rheinau
date: 2026-05-14
author: Alexander
difficulty: T1
tags: [winterthur, rheinau, wallfahrt, pilgern]
seasons: 4-9
summary:
heroAlt:
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
</script>
## Von Winterthur über Feld und Wald zur ehemaligen Klosterkirche Rheinau
An Christi Himmelfahrt (14. Mai 2026) ging es auf eine 8-stündige Pilgerung mit anschliessendem Gottesdienst und Apéro riche mit Pater Ramm, organisiert durch die Christkönigjugend [(ckj.ch)](https://ckj.ch).
Mit einem dann doch nicht so langsamen Tempo wurden die fast 26 km bis 14:30 Uhr zurückgelegt.
### Anreise
Start war direkt beim Bahnhof Winterthur, was die Anreise erleichtert.
<JourneyPlanner from="<current location>" to="Winterthur, Hauptbahnhof" toFixed time="08:00" target="arrival"/>
### Route
Kurz nach 8 Uhr ging es in Winterthur los. Nach ca. 30 Minuten durch die Stadt ging es über Feldwege und geteerte Fusswege Richtung Norden.
Das Wetter hielt leider nicht lange, starker Regen erschwerte das Lesen von Liedtexten für Leute ohne Regenschirm.
<HikeImage idx={0} />
<HikeImage idx={1} />
Immer wieder gab es jedoch auch Pausen im Regen und man konnte das vibrante Frühlingsgrün von Feldern und Wald geniessen.
Der starke Regen hat auch das Aufnehmen weiterer Fotos verhindert, weswegen erst am Ziel wieder viele Bilder zu sehen sind.
<HikeImage idx={2} />
<HikeImage idx={3} />
## Ziel
Nach 8 Stunden war die Klosterkirche Rheinau hinter den Baumzipfeln zu erkennen.
Der Rhein macht hier eine starke Rechtskurve, wodurch die Klosterinsel genau am Bogen des Rheins Richtung Osten zeigt.
<HikeImage idx={4} />
<HikeImage idx={5} />
<HikeImage idx={6} />
<HikeImage idx={7} />
<HikeImage idx={8} />
<HikeImage idx={9} />
## Heimreise
Von der Klosterinsel Rheinau fährt stündlich ein Bus zum Bahnhof Marthalen.
Dann geht es via Zug mit eventuellem Umsteigen in Winterthur nach Hause.
<JourneyPlanner fromFixed from="Rheinau, Unterstadt" to="<current location>" time="16:00" target="departure"/>
File diff suppressed because it is too large Load Diff
+65 -3
View File
@@ -1,6 +1,7 @@
import type { Handle, HandleServerError, ServerInit } from "@sveltejs/kit"
import { redirect } from "@sveltejs/kit"
import { sequence } from "@sveltejs/kit/hooks"
import { building } from "$app/environment"
import * as auth from "./auth"
import { initializeScheduler } from "./lib/server/scheduler"
import { dbConnect } from "./utils/db"
@@ -25,6 +26,29 @@ async function htmlLang({ event, resolve }: Parameters<Handle>[0]) {
});
}
/** Apply headers to a response, transparently cloning it if the original
* has immutable headers. Auth.js (and certain fetch error/redirect responses)
* hand back frozen Headers, and a direct `.set()` on those throws
* `TypeError: immutable` — which would mask the underlying error and 500
* the request. Cloning preserves the body stream and status. */
function applyHeaders(response: Response, entries: Array<[string, string]>): Response {
try {
for (const [k, v] of entries) response.headers.set(k, v);
return response;
} catch (err) {
if (err instanceof TypeError) {
const headers = new Headers(response.headers);
for (const [k, v] of entries) headers.set(k, v);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
}
throw err;
}
}
/** Routes that must never appear in search-engine indexes. Search-results pages
* are thin/duplicate content; admin/edit/auth-walled pages have no public value
* and shouldn't burn crawl budget. Sets X-Robots-Tag rather than per-page meta
@@ -46,11 +70,34 @@ const NOINDEX_PATTERNS: RegExp[] = [
async function noindex({ event, resolve }: Parameters<Handle>[0]) {
const response = await resolve(event);
if (NOINDEX_PATTERNS.some((p) => p.test(event.url.pathname))) {
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
return applyHeaders(response, [['X-Robots-Tag', 'noindex, nofollow']]);
}
return response;
}
/** Baseline security headers, set on every response.
*
* - X-Frame-Options + CSP frame-ancestors block this site from being
* iframed onto attacker pages (clickjacking on /login, /cospend,
* /fitness, etc.). Both directives are sent: modern browsers honour
* frame-ancestors and ignore the legacy header; older ones (IE11) only
* understand X-Frame-Options.
* - Strict-Transport-Security tells browsers to refuse plain-HTTP for
* bocken.org and any subdomain for one year, preventing protocol
* downgrade. Browsers ignore the header on http:// loads, so dev on
* localhost is unaffected. `preload` deliberately omitted — the HSTS
* preload list is hard to leave; revisit only after a stable production
* deployment.
*/
async function securityHeaders({ event, resolve }: Parameters<Handle>[0]) {
const response = await resolve(event);
return applyHeaders(response, [
['X-Frame-Options', 'DENY'],
['Content-Security-Policy', "frame-ancestors 'none'"],
['Strict-Transport-Security', 'max-age=31536000; includeSubDomains']
]);
}
async function timing({ event, resolve }: Parameters<Handle>[0]) {
const marks: Record<string, number> = {};
event.locals.timing = {
@@ -72,11 +119,19 @@ async function timing({ event, resolve }: Parameters<Handle>[0]) {
const header = Object.entries(marks)
.map(([k, v]) => `${k};dur=${v.toFixed(1)}`)
.join(', ');
response.headers.set('Server-Timing', header);
return response;
return applyHeaders(response, [['Server-Timing', header]]);
}
export const init: ServerInit = async () => {
// SvelteKit runs prerendering/analysis inside a worker_threads worker (see
// @sveltejs/kit utils/fork.js) whose JS heap is capped well below the main
// thread's. `init` fires there too, so warming the romcal cache during a
// build exhausts that worker's heap → ERR_WORKER_OUT_OF_MEMORY and a failed
// build. None of it is needed at build time: no prerendered route touches the
// DB, and connecting to Mongo / starting the payment scheduler from a build
// is undesirable regardless. Skip startup work while building.
if (building) return;
console.log('🚀 Server starting - initializing database connection...');
try {
await dbConnect();
@@ -172,8 +227,14 @@ async function authorization({ event, resolve }: Parameters<Handle>[0]) {
return resolve(event);
}
/** Browser/crawler probes for these paths are routine 404s — not bugs.
* Skip the noisy console.error so real errors stay visible. */
const SILENT_404_PATHS = new Set(['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']);
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
if (!(status === 404 && SILENT_404_PATHS.has(event.url.pathname))) {
console.error('Error occurred:', { error, status, message, url: event.url.pathname });
}
const bibleQuote = await getRandomVerse(event.fetch, event.url.pathname);
const isEnglish = event.url.pathname.startsWith('/faith/') || event.url.pathname.startsWith('/recipes/');
@@ -189,6 +250,7 @@ export const handle: Handle = sequence(
timing,
htmlLang,
noindex,
securityHeaders,
auth.handle,
authorization
);
+37
View File
@@ -0,0 +1,37 @@
# Public responsive image assets
Drop public source images here, then render them with `$lib/components/Image.svelte`.
At build time `@sveltejs/enhanced-img` (vite-imagetools + sharp) processes every
raster image in this folder into AVIF/WebP at multiple widths and strips EXIF.
Output is a public, hashed, immutable build asset.
```svelte
<script>
import Image from '$lib/components/Image.svelte';
</script>
<!-- lazy by default; `src` is relative to this folder -->
<Image src="hero.jpg" alt="…" />
<!-- above-the-fold / LCP image: load eagerly -->
<Image src="hero.jpg" alt="…" lazy={false} />
<!-- full-width image: pass `sizes` so smaller screens fetch smaller files -->
<Image src="banner.jpg" alt="…" sizes="min(1280px, 100vw)" />
<!-- subfolders work too -->
<Image src="blog/cover.png" alt="…" />
```
For **private, auth-gated** images use `<Image src="…" private />` and put the
source in `../private-images/` instead — see that folder's README.
Notes:
- Provide images at ~2× the displayed size so HiDPI screens stay sharp;
processing only ever scales **down**.
- SVGs are not processed here — import them directly instead.
- First build is slow (encoding); results are cached in
`node_modules/.cache/imagetools`.
- These sources are committed (they're public site assets).
+45
View File
@@ -0,0 +1,45 @@
# Private (auth-gated) image sources
Drop **private** source images here, then render them with
`<Image src="…" private />` from `$lib/components/Image.svelte`.
These can't use `@sveltejs/enhanced-img` — its output is a public asset. Instead
`scripts/build-private-images.ts` (runs at `prebuild`) encodes each image into
AVIF/WebP at multiple widths into `private-assets/` (gitignored, outside the
client bundle) and writes `src/lib/data/privateImages.generated.ts`. The bytes
are served only through the auth-gated endpoint
`src/routes/private-images/[...file]/+server.ts`.
```svelte
<script>
import Image from '$lib/components/Image.svelte';
</script>
<!-- `src` is relative to THIS folder; shows a lock badge -->
<Image src="receipt.jpg" private alt="…" />
<!-- gate rendering behind your own auth check too -->
{#if data.session}
<Image src="family/2024.jpg" private alt="…" sizes="min(1000px, 100vw)" />
{/if}
```
Setup / notes:
- **Dev:** run `pnpm exec vite-node scripts/build-private-images.ts` once (and
after adding/changing images) so the manifest + `private-assets/` exist. You
must be logged in for the gated endpoint to serve the bytes.
- **Prod (one-time):** add an nginx `internal` location so the bytes are only
reachable via the endpoint's `X-Accel-Redirect`:
```nginx
location /protected-images/ {
internal;
alias /var/www/static/private-images/;
}
```
`scripts/deploy.sh` rsyncs `private-assets/` → `/var/www/static/private-images/`.
- These source images are **gitignored** (private + large). Back them up
separately.
- SVGs are not processed here.
+656
View File
@@ -0,0 +1,656 @@
<script lang="ts">
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Calendar from '@lucide/svelte/icons/calendar';
import Clock from '@lucide/svelte/icons/clock';
import X from '@lucide/svelte/icons/x';
import { m } from '$lib/js/commonI18n';
import type { CommonLang } from '$lib/js/commonI18n';
/**
* Pill-styled date + (optional) time picker. Built to share the look of
* `DatePicker.svelte` while operating on numeric `unix-ms` timestamps so it
* can be dropped into any `wp.timestamp: number | null` shaped store without
* an intermediate string conversion at every callsite.
*
* Features:
* - `mode='date'` hides the time pill (useful for waypoints that don't
* need a time anchor in their GPX export).
* - `inheritedValue` lets a caller suggest a tentative default (e.g. the
* nearest timestamped sibling's date). It's rendered in italic with a
* dashed outline; an "Übernehmen" button commits it to the bound value.
* User edits via the calendar, the time input, the day arrows, or any
* nudge button also implicitly commit the inherited value.
* - `nudgeMinutes` renders a row of ±N minute quick-adjust buttons. Only
* shown when a value is set and `mode='datetime'`.
* - `required` hides the clear button (e.g. first/last waypoint must keep
* a timestamp for the export's interpolation to bind).
*/
interface Props {
value: number | null | undefined;
mode?: 'date' | 'datetime';
inheritedValue?: number | null;
nudgeMinutes?: number[];
required?: boolean;
lang?: CommonLang;
min?: number | null;
max?: number | null;
/** Optional extra CSS class on the outer wrapper. */
class?: string;
}
let {
value = $bindable<number | null | undefined>(null),
mode = 'datetime',
inheritedValue = null,
nudgeMinutes = [],
required = false,
lang = 'de',
min = null,
max = null,
class: extraClass = ''
}: Props = $props();
const t = $derived(m[lang]);
let open = $state(false);
let pickerRef = $state<HTMLDivElement | null>(null);
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const weekdays = $derived(lang === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN);
const months = $derived(lang === 'de' ? MONTHS_DE : MONTHS_EN);
// When the bound value is null but the caller supplied an inherited default,
// the pill displays the inherited timestamp in "tentative" styling. The
// `effective` getter is what every formatting/derived computation runs on.
const effective = $derived<number | null>((value ?? inheritedValue) ?? null);
const inheritedActive = $derived(value == null && inheritedValue != null);
function pad(n: number): string {
return n.toString().padStart(2, '0');
}
function toDateStr(ts: number | null): string {
if (ts == null) return '';
const d = new Date(ts);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function toTimeStr(ts: number | null): string {
if (ts == null) return '';
const d = new Date(ts);
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const dateStr = $derived(toDateStr(effective));
const timeStr = $derived(toTimeStr(effective));
const todayStr = new Date().toISOString().slice(0, 10);
const dateLabel = $derived.by(() => {
if (!dateStr) return t.select_date;
if (dateStr === todayStr && lang in t) return t.today;
const d = new Date(dateStr + 'T12:00:00');
return d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', {
weekday: 'short', month: 'short', day: 'numeric'
});
});
// Calendar view month — independent of selected value, updated when `value`
// or the dropdown opens to follow the relevant month.
let viewYear = $state(new Date().getFullYear());
let viewMonth = $state(new Date().getMonth());
$effect(() => {
const ref = effective ?? Date.now();
const d = new Date(ref);
viewYear = d.getFullYear();
viewMonth = d.getMonth();
});
function isDisabled(ts: number): boolean {
if (min != null && ts < min) return true;
if (max != null && ts > max) return true;
return false;
}
function commit(ts: number | null) {
if (ts != null && isDisabled(ts)) return;
value = ts;
}
function buildTimestamp(date: string, time: string): number | null {
if (!date) return null;
const [y, m, d] = date.split('-').map((n) => parseInt(n, 10));
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null;
let hh = 12, mm = 0;
if (time) {
const parts = time.split(':').map((n) => parseInt(n, 10));
if (Number.isFinite(parts[0])) hh = parts[0];
if (Number.isFinite(parts[1])) mm = parts[1];
}
return new Date(y, m - 1, d, hh, mm, 0, 0).getTime();
}
function selectDay(date: string) {
// When committing, preserve the time-of-day of the current effective
// value so picking a new date doesn't reset to noon and discard the
// user's already-tuned time.
commit(buildTimestamp(date, mode === 'datetime' ? timeStr || '12:00' : '12:00'));
open = false;
}
function updateTimeInput(value: string) {
if (!value) return;
commit(buildTimestamp(dateStr || toDateStr(Date.now()), value));
}
function navDay(delta: number) {
const ref = effective ?? Date.now();
const d = new Date(ref);
d.setDate(d.getDate() + delta);
commit(d.getTime());
}
function navMonth(delta: number) {
viewMonth += delta;
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
}
function nudge(deltaMin: number) {
const base = effective;
if (base == null) return;
commit(base + deltaMin * 60_000);
}
function applyInherited() {
if (inheritedValue == null) return;
commit(inheritedValue);
}
function clear() {
value = null;
open = false;
}
function goNow() {
commit(Date.now());
open = false;
}
const calendarDays = $derived.by(() => {
const first = new Date(viewYear, viewMonth, 1);
let startDay = first.getDay() - 1;
if (startDay < 0) startDay = 6;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate();
type Day = {
date: string;
day: number;
currentMonth: boolean;
isToday: boolean;
isSelected: boolean;
disabled: boolean;
};
const days: Day[] = [];
const pushDay = (y: number, mo: number, d: number, currentMonth: boolean) => {
const date = `${y}-${pad(mo + 1)}-${pad(d)}`;
const ts = new Date(y, mo, d, 12, 0, 0, 0).getTime();
days.push({
date, day: d, currentMonth,
isToday: date === todayStr,
isSelected: date === dateStr && value != null,
disabled: isDisabled(ts)
});
};
for (let i = startDay - 1; i >= 0; i--) {
const d = daysInPrevMonth - i;
const mo = viewMonth === 0 ? 11 : viewMonth - 1;
const y = viewMonth === 0 ? viewYear - 1 : viewYear;
pushDay(y, mo, d, false);
}
for (let d = 1; d <= daysInMonth; d++) {
pushDay(viewYear, viewMonth, d, true);
}
const remaining = 7 - (days.length % 7);
if (remaining < 7) {
for (let d = 1; d <= remaining; d++) {
const mo = viewMonth === 11 ? 0 : viewMonth + 1;
const y = viewMonth === 11 ? viewYear + 1 : viewYear;
pushDay(y, mo, d, false);
}
}
return days;
});
function handleClickOutside(e: MouseEvent) {
if (pickerRef && e.target instanceof Node && !pickerRef.contains(e.target)) {
open = false;
}
}
$effect(() => {
if (open) {
document.addEventListener('pointerdown', handleClickOutside);
return () => document.removeEventListener('pointerdown', handleClickOutside);
}
});
const negativeNudges = $derived(
[...nudgeMinutes].filter((n) => n < 0).sort((a, b) => a - b)
);
const positiveNudges = $derived(
[...nudgeMinutes].filter((n) => n > 0).sort((a, b) => a - b)
);
const showNudge = $derived(
mode === 'datetime' && effective != null && nudgeMinutes.length > 0
);
const showClear = $derived(!required && value != null);
</script>
<div class="dtp {extraClass}" bind:this={pickerRef}>
<div class="dtp-pill" class:inherited={inheritedActive} class:empty={effective == null}>
<button type="button" class="dtp-arrow" onclick={() => navDay(-1)} aria-label="-1d">
<ChevronLeft size={16} />
</button>
<button type="button" class="dtp-display" onclick={() => (open = !open)}>
<Calendar size={14} />
<span class="dtp-date-label">{dateLabel}</span>
</button>
<button type="button" class="dtp-arrow" onclick={() => navDay(1)} aria-label="+1d">
<ChevronRight size={16} />
</button>
{#if mode === 'datetime'}
{#if showNudge && negativeNudges.length > 0}
<div class="dtp-nudge dtp-nudge-neg" role="group" aria-label={t.select_time}>
{#each negativeNudges as delta (delta)}
<button type="button" onclick={() => nudge(delta)}>
{Math.abs(delta)}
</button>
{/each}
</div>
{/if}
<label class="dtp-time" title={t.select_time}>
<Clock size={13} aria-hidden="true" />
<input
type="time"
value={timeStr}
onchange={(e) => updateTimeInput(e.currentTarget.value)}
aria-label={t.select_time}
/>
</label>
{#if showNudge && positiveNudges.length > 0}
<div class="dtp-nudge dtp-nudge-pos" role="group" aria-label={t.select_time}>
{#each positiveNudges as delta (delta)}
<button type="button" onclick={() => nudge(delta)}>
+{delta}
</button>
{/each}
</div>
{/if}
{/if}
</div>
{#if open}
<div class="dtp-dropdown" role="dialog" aria-label={t.select_date}>
<div class="dtp-header">
<button type="button" class="dtp-nav" onclick={() => navMonth(-1)} aria-label="<<">
<ChevronLeft size={16} />
</button>
<span class="dtp-month-label">{months[viewMonth]} {viewYear}</span>
<button type="button" class="dtp-nav" onclick={() => navMonth(1)} aria-label=">>">
<ChevronRight size={16} />
</button>
</div>
<div class="dtp-weekdays">
{#each weekdays as wd (wd)}
<span class="dtp-wd">{wd}</span>
{/each}
</div>
<div class="dtp-grid">
{#each calendarDays as day (day.date)}
<button
type="button"
class="dtp-day"
class:other-month={!day.currentMonth}
class:today={day.isToday}
class:selected={day.isSelected}
class:disabled={day.disabled}
disabled={day.disabled}
onclick={() => selectDay(day.date)}
>
{day.day}
</button>
{/each}
</div>
<button type="button" class="dtp-today-btn" onclick={goNow}>
{mode === 'datetime' ? t.now : t.today}
</button>
</div>
{/if}
{#if inheritedActive}
<button type="button" class="dtp-accept" onclick={applyInherited}>
{t.apply_inherited}
</button>
{/if}
{#if showClear}
<button
type="button"
class="dtp-clear"
onclick={clear}
aria-label={t.clear}
title={t.clear}
>
<X size={12} strokeWidth={2.25} />
</button>
{/if}
</div>
<style>
.dtp {
position: relative;
display: inline-flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
/* Reserve room above the pill for the absolutely-positioned clear
* button so it doesn't visually crash into the row above. */
padding-top: 0.4rem;
}
.dtp-pill {
display: flex;
align-items: stretch;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
overflow: hidden;
font-size: 0.8rem;
}
.dtp-pill.inherited {
border-style: dashed;
background: color-mix(in oklab, var(--color-bg-tertiary) 70%, transparent);
}
.dtp-pill.inherited .dtp-date-label,
.dtp-pill.inherited .dtp-time input {
color: var(--color-text-tertiary);
font-style: italic;
}
.dtp-pill.empty .dtp-date-label {
color: var(--color-text-tertiary);
}
.dtp-arrow {
display: flex;
align-items: center;
justify-content: center;
padding: 0.35rem 0.4rem;
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-normal), background var(--transition-normal);
}
.dtp-arrow:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.dtp-display {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
background: none;
border: none;
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
color: var(--color-text-secondary);
font: inherit;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color var(--transition-normal), background var(--transition-normal);
}
.dtp-display:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.dtp-time {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0 0.55rem;
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.dtp-time input {
appearance: none;
background: transparent;
border: 0;
padding: 0;
font: inherit;
font-variant-numeric: tabular-nums;
color: var(--color-text-primary);
min-width: 4.4em;
cursor: pointer;
}
.dtp-time input::-webkit-calendar-picker-indicator {
opacity: 0.5;
cursor: pointer;
}
/* Nudge clusters live INSIDE the pill, flanking the time input. They
* share the pill's chrome (no extra borders, no rounded corners — the pill
* itself clips them). */
.dtp-nudge {
display: inline-flex;
}
.dtp-nudge button {
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-secondary);
font: inherit;
font-size: 0.72rem;
padding: 0 0.5rem;
cursor: pointer;
min-width: 2.2rem;
font-variant-numeric: tabular-nums;
transition: color var(--transition-normal), background var(--transition-normal);
}
.dtp-nudge-neg {
border-left: 1px solid var(--color-border);
}
.dtp-nudge-neg button + button {
border-left: 1px solid var(--color-border);
}
.dtp-nudge-pos button + button {
border-left: 1px solid var(--color-border);
}
.dtp-nudge button:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
/* Dropdown calendar — mirrors DatePicker.svelte */
.dtp-dropdown {
position: absolute;
top: calc(100% + 0.4rem);
left: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 0.6rem;
z-index: 200;
min-width: 260px;
}
.dtp-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.4rem;
}
.dtp-month-label {
font-size: 0.8rem;
font-weight: 700;
color: var(--color-text-primary);
}
.dtp-nav {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
transition: background var(--transition-normal), color var(--transition-normal);
}
.dtp-nav:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.dtp-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 0.2rem;
}
.dtp-wd {
text-align: center;
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-tertiary);
padding: 0.2rem 0;
}
.dtp-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.dtp-day {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-primary);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-normal), color var(--transition-normal);
}
.dtp-day:hover {
background: var(--color-bg-elevated);
}
.dtp-day.other-month {
color: var(--color-text-tertiary);
}
.dtp-day.disabled {
opacity: 0.3;
cursor: not-allowed;
}
.dtp-day.today {
font-weight: 700;
box-shadow: inset 0 0 0 1.5px var(--color-primary);
}
.dtp-day.selected {
background: var(--color-primary);
color: var(--color-text-on-primary);
font-weight: 700;
}
.dtp-day.selected:hover {
background: var(--color-primary-hover);
}
.dtp-today-btn {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.3rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-primary);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition-normal);
}
.dtp-today-btn:hover {
background: var(--color-bg-elevated);
}
.dtp-accept {
appearance: none;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font: inherit;
font-size: 0.72rem;
padding: 0.25rem 0.6rem;
border-radius: var(--radius-pill);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.dtp-accept:hover {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
/* Clear button: top-right corner badge, mirrors close-X affordances on
* dismissable chips elsewhere. Sits slightly outside the pill so it
* doesn't crowd the date/time controls. */
.dtp-clear {
position: absolute;
top: -4px;
right: -4px;
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 50%;
color: var(--color-text-tertiary);
cursor: pointer;
box-shadow: var(--shadow-sm);
transition:
color var(--transition-fast),
background var(--transition-fast),
border-color var(--transition-fast),
transform var(--transition-fast);
z-index: 2;
}
.dtp-clear:hover {
color: var(--color-text-on-primary);
background: var(--red);
border-color: var(--red);
transform: scale(1.08);
}
</style>
+6 -2
View File
@@ -47,14 +47,18 @@ footer {
═══════════════════════════════════════════ */
nav {
position: sticky;
top: calc(12px + env(safe-area-inset-top, 0px));
/* Without a safe-area inset (regular browser tabs), keep the comfortable
12px gap. With an inset present (PWA / Tauri shell with notch / status
bar), drop the extra 12px so the bar sits just 4px below the inset
instead of stacking ~56px down. */
top: max(12px, calc(env(safe-area-inset-top, 0px) + 4px));
z-index: 100;
display: flex;
align-items: center;
height: var(--header-h);
gap: 0.4rem;
padding: 0 0.8rem;
margin: calc(12px + env(safe-area-inset-top, 0px)) auto 0;
margin: max(12px, calc(env(safe-area-inset-top, 0px) + 4px)) auto 0;
width: fit-content;
max-width: calc(100% - 1.5rem);
border-radius: 100px;
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts" module>
import type { Picture } from '@sveltejs/enhanced-img';
// Build-time map of every PUBLIC raster image under src/lib/assets/images/.
// `query: { enhanced: true }` routes each match through @sveltejs/enhanced-img
// (vite-imagetools + sharp), which generates AVIF/WebP at multiple widths and
// returns a Picture that <enhanced:img> renders as a <picture>. Eager so the
// lookup below stays synchronous. SVGs are excluded — enhanced-img only
// supports them statically, and they need no rasterising anyway.
const sources = import.meta.glob(
'/src/lib/assets/images/**/*.{avif,gif,heif,jpeg,jpg,png,tiff,webp}',
{ eager: true, query: { enhanced: true } }
) as Record<string, { default: Picture }>;
</script>
<script lang="ts">
import { dev } from '$app/environment';
import Lock from '@lucide/svelte/icons/lock';
// PRIVATE images can't use enhanced-img (its output is public). They go
// through the parallel sharp pipeline (scripts/build-private-images.ts) and
// are served by the auth-gated /private-images/ endpoint. The manifest is
// generated at prebuild; run `vite-node scripts/build-private-images.ts` once
// for dev.
import { PRIVATE_IMAGES } from '$lib/data/privateImages.generated';
interface Props {
/** Path to the source image. Public: relative to src/lib/assets/images/.
* Private: relative to src/lib/assets/private-images/. e.g. "hero.jpg"
* or "blog/cover.png". A leading slash is tolerated. */
src: string;
/** Alt text. Always provide one for non-decorative images. */
alt?: string;
/** Lazy-load below the fold (default). Set false for above-the-fold /
* LCP images, which should load eagerly. */
lazy?: boolean;
/** Auth-gate this image: served only to logged-in users via the
* /private-images/ endpoint, with a lock badge. The bytes are never a
* public asset. Render these behind your own auth check too — anonymous
* viewers get a "locked" placeholder instead of the image. */
private?: boolean;
/** Responsive `sizes`. When set, smaller screens fetch smaller files;
* omit for a plain 1x/2x pair (public) or the full ladder (private). */
sizes?: string;
/** Extra class(es) forwarded to the underlying <img>. */
class?: string;
/** Any other <img> attribute (width, height, fetchpriority, style, …). */
[key: string]: unknown;
}
let {
src,
alt = '',
lazy = true,
private: isPrivate = false,
sizes,
class: className,
...rest
}: Props = $props();
const key = $derived(src.replace(/^\/+/, ''));
// Public: enhanced-img Picture, looked up by root-relative glob key.
const picture = $derived(isPrivate ? undefined : sources[`/src/lib/assets/images/${key}`]?.default);
// Private: responsive variant with auth-gated /private-images/ URLs.
const variant = $derived(isPrivate ? PRIVATE_IMAGES[key] : undefined);
// Anonymous viewers get a 401 from /private-images/; swap the broken image
// for a locked placeholder when that happens.
let locked = $state(false);
$effect(() => {
if (!dev) return;
if (isPrivate && !variant) {
console.warn(
`[Image] No private build-time asset for "${src}". Place it under ` +
`src/lib/assets/private-images/ and re-run scripts/build-private-images.ts.`
);
} else if (!isPrivate && !picture) {
console.warn(
`[Image] No build-time asset for "${src}". ` +
`Place it under src/lib/assets/images/ (path relative to that dir).`
);
}
});
</script>
{#if isPrivate}
{#if variant}
<span class="g-private-image" class:locked>
<picture>
<source type="image/avif" srcset={variant.srcsetAvif} {sizes} />
<source type="image/webp" srcset={variant.srcsetWebp} {sizes} />
<img
src={variant.src}
{alt}
width={variant.width}
height={variant.height}
class={className}
loading={lazy ? 'lazy' : 'eager'}
decoding="async"
onerror={() => (locked = true)}
{...rest}
/>
</picture>
<span class="g-private-badge" title="Privates Bild — nur für eingeloggte Benutzer sichtbar">
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
privat
</span>
{#if locked}
<span class="g-private-locked">
<Lock size={20} strokeWidth={2} aria-hidden="true" />
Anmeldung erforderlich
</span>
{/if}
</span>
{/if}
{:else if picture}
<enhanced:img
src={picture}
{alt}
{sizes}
class={className}
loading={lazy ? 'lazy' : 'eager'}
decoding="async"
{...rest}
/>
{/if}
<style>
/* The colon in the tag name must be escaped in a selector. enhanced-img
* rewrites this to target the generated <img>. */
enhanced\:img {
display: block;
max-width: 100%;
height: auto;
}
.g-private-image {
position: relative;
display: inline-block;
max-width: 100%;
}
.g-private-image picture,
.g-private-image img {
display: block;
max-width: 100%;
height: auto;
}
/* Lock badge — mirrors HikeImage's `.private`. */
.g-private-badge {
position: absolute;
top: 0.6rem;
left: 0.6rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.18rem 0.5rem;
border-radius: var(--radius-pill);
background: rgb(0 0 0 / 0.55);
color: #fff;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Shown when the gated request 401s (anonymous viewer). */
.g-private-image.locked img {
visibility: hidden;
}
.g-private-locked {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-align: center;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
import { page } from '$app/state';
import Lock from '@lucide/svelte/icons/lock';
import type { Snippet } from 'svelte';
interface Props {
/** Show the small "privat" lock chip above the content (default true). */
badge?: boolean;
children: Snippet;
}
let { badge = true, children }: Props = $props();
// Visible only to logged-in viewers. Pages that use this should be rendered
// per request (e.g. the hike detail page is `prerender = false`) so the
// session is live and, for anonymous visitors, the content is omitted from
// the SSR HTML.
//
// NOTE: this is *cosmetic* gating, not byte-gating like a private image.
// The prose is compiled into the page's JS chunk, which ships to every
// visitor — a determined anonymous user can read it in the bundle. Use it
// for "members-only" notes, never for secrets.
const canSee = $derived(!!page.data.session?.user);
</script>
{#if canSee}
<div class="private-prose">
{#if badge}
<span class="badge" title="Privat — nur für eingeloggte Benutzer sichtbar">
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
privat
</span>
{/if}
{@render children()}
</div>
{/if}
<style>
.private-prose {
position: relative;
margin: 1.5rem 0;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-primary);
border-radius: var(--radius-md);
background: var(--color-bg-tertiary);
}
/* Trim the first/last rendered block's margins so the box hugs its content. */
.private-prose :global(> :first-child) {
margin-top: 0;
}
.private-prose :global(> :last-child) {
margin-bottom: 0;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.5rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.18rem 0.5rem;
border-radius: var(--radius-pill);
background: var(--color-primary);
color: var(--color-text-on-primary);
}
</style>
+326
View File
@@ -0,0 +1,326 @@
<script lang="ts">
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Clock from '@lucide/svelte/icons/clock';
import { m } from '$lib/js/commonI18n';
import type { CommonLang } from '$lib/js/commonI18n';
/**
* Pill-styled time picker, sibling to `DatePicker.svelte` (date) and
* `DateTimePicker.svelte` (combined). Operates on a plain `"HH:MM"` string so
* it drops straight into 24-hour API params and `<input type="time">`-shaped
* stores.
*
* - Chevron arrows nudge by `step` minutes (wrapping across the hour).
* - The display opens a two-column hour / minute dropdown.
* - Optional `min` / `max` (also `"HH:MM"`) disable out-of-range cells.
*/
interface Props {
value?: string;
/** Minute granularity for the dropdown + chevron nudges. */
step?: number;
min?: string;
max?: string;
lang?: CommonLang;
/** Optional extra CSS class on the outer wrapper. */
class?: string;
}
let {
value = $bindable(''),
step = 5,
min = '',
max = '',
lang = 'de',
class: extraClass = ''
}: Props = $props();
const t = $derived(m[lang]);
let open = $state(false);
let pickerRef = $state<HTMLDivElement | null>(null);
let hourCol = $state<HTMLDivElement | null>(null);
let minCol = $state<HTMLDivElement | null>(null);
function pad(n: number): string {
return n.toString().padStart(2, '0');
}
function parse(v: string): { h: number; m: number } | null {
const mt = /^(\d{1,2}):(\d{2})$/.exec(v ?? '');
if (!mt) return null;
const h = Number(mt[1]);
const mm = Number(mt[2]);
if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
return { h, m: mm };
}
const current = $derived(parse(value));
const label = $derived(current ? `${pad(current.h)}:${pad(current.m)}` : t.select_time);
const hours = Array.from({ length: 24 }, (_, i) => i);
const minutes = $derived(
Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step).filter((mm) => mm < 60)
);
function outOfRange(time: string): boolean {
if (min && time < min) return true;
if (max && time > max) return true;
return false;
}
function hourDisabled(h: number): boolean {
for (let mm = 0; mm < 60; mm += step) {
if (!outOfRange(`${pad(h)}:${pad(mm)}`)) return false;
}
return true;
}
function minuteDisabled(mm: number): boolean {
const h = current?.h ?? -1;
if (h < 0) return false;
return outOfRange(`${pad(h)}:${pad(mm)}`);
}
function commit(h: number, mm: number) {
const next = `${pad(h)}:${pad(mm)}`;
if (outOfRange(next)) return;
value = next;
}
function selectHour(h: number) {
commit(h, current?.m ?? 0);
}
function selectMinute(mm: number) {
commit(current?.h ?? new Date().getHours(), mm);
}
function nudge(delta: number) {
const base = current ?? { h: new Date().getHours(), m: 0 };
let total = (base.h * 60 + base.m + delta) % (24 * 60);
if (total < 0) total += 24 * 60;
commit(Math.floor(total / 60), total % 60);
}
function setNow() {
const d = new Date();
let mm = Math.round(d.getMinutes() / step) * step;
let h = d.getHours();
if (mm >= 60) {
mm = 0;
h = (h + 1) % 24;
}
commit(h, mm);
open = false;
}
// Centre the selected cells when the dropdown opens.
function centreCol(col: HTMLDivElement | null) {
if (!col) return;
const sel = col.querySelector<HTMLElement>('.tp-cell.selected');
if (sel) col.scrollTop = sel.offsetTop - col.clientHeight / 2 + sel.clientHeight / 2;
}
$effect(() => {
if (open) {
centreCol(hourCol);
centreCol(minCol);
}
});
function handleClickOutside(e: MouseEvent) {
if (pickerRef && e.target instanceof Node && !pickerRef.contains(e.target)) {
open = false;
}
}
$effect(() => {
if (open) {
document.addEventListener('pointerdown', handleClickOutside);
return () => document.removeEventListener('pointerdown', handleClickOutside);
}
});
</script>
<div class="tp {extraClass}" bind:this={pickerRef}>
<div class="tp-pill" class:empty={current == null}>
<button type="button" class="tp-arrow" onclick={() => nudge(-step)} aria-label="{step} min">
<ChevronLeft size={16} />
</button>
<button type="button" class="tp-display" onclick={() => (open = !open)} aria-label={t.select_time}>
<Clock size={14} aria-hidden="true" />
<span class="tp-label">{label}</span>
</button>
<button type="button" class="tp-arrow" onclick={() => nudge(step)} aria-label="+{step} min">
<ChevronRight size={16} />
</button>
</div>
{#if open}
<div class="tp-dropdown" role="dialog" aria-label={t.select_time}>
<div class="tp-cols">
<div class="tp-col" bind:this={hourCol} role="listbox" aria-label="Stunde">
{#each hours as h (h)}
<button
type="button"
class="tp-cell"
class:selected={current?.h === h}
disabled={hourDisabled(h)}
onclick={() => selectHour(h)}
>
{pad(h)}
</button>
{/each}
</div>
<div class="tp-col" bind:this={minCol} role="listbox" aria-label="Minute">
{#each minutes as mm (mm)}
<button
type="button"
class="tp-cell"
class:selected={current?.m === mm}
disabled={minuteDisabled(mm)}
onclick={() => selectMinute(mm)}
>
{pad(mm)}
</button>
{/each}
</div>
</div>
<button type="button" class="tp-now" onclick={setNow}>{t.now}</button>
</div>
{/if}
</div>
<style>
.tp {
position: relative;
display: inline-flex;
align-items: center;
}
.tp-pill {
display: flex;
align-items: stretch;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
overflow: hidden;
font-size: 0.8rem;
}
.tp-arrow {
display: flex;
align-items: center;
justify-content: center;
padding: 0.35rem 0.4rem;
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-normal), background var(--transition-normal);
}
.tp-arrow:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.tp-display {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
background: none;
border: none;
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
color: var(--color-text-secondary);
font: inherit;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color var(--transition-normal), background var(--transition-normal);
}
.tp-display:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.tp-label {
font-variant-numeric: tabular-nums;
}
.tp-pill.empty .tp-label {
color: var(--color-text-tertiary);
}
/* Dropdown — mirrors DatePicker / DateTimePicker chrome. */
.tp-dropdown {
position: absolute;
top: calc(100% + 0.4rem);
left: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 0.5rem;
z-index: 200;
}
.tp-cols {
display: flex;
gap: 0.3rem;
}
.tp-col {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 11rem;
overflow-y: auto;
padding-right: 0.15rem;
scrollbar-width: thin;
}
.tp-cell {
display: flex;
align-items: center;
justify-content: center;
min-width: 2.6rem;
padding: 0.3rem 0.5rem;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-primary);
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
font-weight: 500;
cursor: pointer;
transition: background var(--transition-normal), color var(--transition-normal);
}
.tp-cell:hover:not(:disabled) {
background: var(--color-bg-elevated);
}
.tp-cell:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.tp-cell.selected {
background: var(--color-primary);
color: var(--color-text-on-primary);
font-weight: 700;
}
.tp-cell.selected:hover {
background: var(--color-primary-hover);
}
.tp-now {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.3rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-primary);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition-normal);
}
.tp-now:hover {
background: var(--color-bg-elevated);
}
</style>
@@ -30,7 +30,7 @@
<i></i>
In meiner Todesstunde rufe mich,
<i></i>
Und heisse zur Dir kommen mich,
Und heisse zu Dir kommen mich,
<i></i>
Damit ich möge loben Dich
<i></i>
@@ -53,10 +53,10 @@
{/if}
<p>
{#if showLatin}
<v lang=la >En ego, o bone et dulcíssime Jesu, ante contspéctum tuum génibusme provólvo ac máximo ánimi ardóre te oro atque obtéstor, ut meum in in cor vívidos fídei, spei et caritátis sensus, atque veram peccatórum meórum pœniténtiam, éaque emendándi firmíssimam voluntátem velis imprímere; dum magno ánimi afféctu et dolóre tua quinque vúlnera mecum ipse consídero ac mente contémplor, illud præ óculis habens, quod jam in ore ponébat tuo David Prophéta de te, o bone Jesu: Fodérunt manus meas et pedes meos; dinumeravérunt ómnio ossa mea (Ps. 21, 17-18)</v>
<v lang=la >En ego, o bone et dulcíssime Jesu, ante conspéctum tuum génibus me provólvo ac máximo ánimi ardóre te oro atque obtéstor, ut meum in cor vívidos fídei, spei et caritátis sensus, atque veram peccatórum meórum pœniténtiam, éaque emendándi firmíssimam voluntátem velis imprímere; dum magno ánimi afféctu et dolóre tua quinque vúlnera mecum ipse consídero ac mente contémplor, illud præ óculis habens, quod jam in ore ponébat tuo David Prophéta de te, o bone Jesu: Fodérunt manus meas et pedes meos; dinumeravérunt ómnia ossa mea (Ps. 21, 17-18)</v>
{/if}
<v lang=de>
Siehe, o gütiger und milder Jesus, ich werfe mich vor Deinen Augen auf die Knie. Inbrünstig bitte und beschwöre ich Dich: Präge meinem Herzen lebendige Gefühle des Glaubens, der Hoffung und der Liebe ein sowie wahre Reue über meine Sünden und den ganz festen Willen, mich zu bessern. Voll Liebe und Schmerz schaue ich Deine fünf Wunden und betrachte sie in meinem Geiste. Dabei halte ich mir vor Augen, was im Hinblick auf Dich, o guter Jesus, schon der Prophet David Dir in den Mund legte: «Sie haben Meine Hände und Meine Füsse durchbohrt; alle meine Gebeine haben sie gezählt.» (Ps. 21, 17-18)
Siehe, o gütiger und milder Jesus, ich werfe mich vor Deinen Augen auf die Knie. Inbrünstig bitte und beschwöre ich Dich: Präge meinem Herzen lebendige Gefühle des Glaubens, der Hoffnung und der Liebe ein sowie wahre Reue über meine Sünden und den ganz festen Willen, mich zu bessern. Voll Liebe und Schmerz schaue ich Deine fünf Wunden und betrachte sie in meinem Geiste. Dabei halte ich mir vor Augen, was im Hinblick auf Dich, o guter Jesus, schon der Prophet David Dir in den Mund legte: «Sie haben Meine Hände und Meine Füsse durchbohrt; alle meine Gebeine haben sie gezählt.» (Ps. 21, 17-18)
</v>
</p>
@@ -8,7 +8,7 @@
<Prayer>
{#snippet children(showLatin, urlLang)}
<p>
{#if showLatin}<v lang=la>Ánima Christi, santífica me.</v>{/if}
{#if showLatin}<v lang=la>Ánima Christi, sanctífica me.</v>{/if}
{#if urlLang=='de'}<v lang=de>Seele Christi, heilige mich.</v>{/if}
{#if urlLang=='en'}<v lang=en>Soul of Christ, sanctify me.</v>{/if}
{#if showLatin}<v lang=la>Corpus Christi, salva me.</v>{/if}
@@ -22,7 +22,7 @@
{#if urlLang=='en'}<v lang=en>Water from the side of Christ, wash me.</v>{/if}
{#if showLatin}<v lang=la>Pássio Christi, confórta me.</v>{/if}
{#if urlLang=='de'}<v lang=de>Leiden Christi, stärke mich.</v>{/if}
{#if urlLang=='en'}<v lang=en>Passion of Christ, strenghten me.</v>{/if}
{#if urlLang=='en'}<v lang=en>Passion of Christ, strengthen me.</v>{/if}
{#if showLatin}<v lang=la>O bone Iesu, exáudi me.</v>{/if}
{#if urlLang=='de'}<v lang=de>O gütiger Jesus, erhöre mich.</v>{/if}
{#if urlLang=='en'}<v lang=en>O good Jesus, hear me.</v>{/if}
@@ -34,12 +34,12 @@
{#if urlLang=='en'}<v lang=en>Separated from Thee let me never be.</v>{/if}
{#if showLatin}<v lang=la>Ab hoste malígno defénde me.</v>{/if}
{#if urlLang=='de'}<v lang=de>Vor dem bösen Feind beschütze mich.</v>{/if}
{#if urlLang=='en'}<v lang=en>From the malignant enemeny, defend me.</v>{/if}
{#if urlLang=='en'}<v lang=en>From the malignant enemy, defend me.</v>{/if}
{#if showLatin}<v lang=la>In hora mortis meæ voca me.</v>{/if}
{#if urlLang=='de'}<v lang=de>In meiner Todesstunde rufe mich,</v>{/if}
{#if urlLang=='en'}<v lang=en>At the hour of death, call me.</v>{/if}
{#if showLatin}<v lang=la>Et iube me veníre ad te,</v>{/if}
{#if urlLang=='de'}<v lang=de>Und heisse zur Dir kommen mich,</v>{/if}
{#if urlLang=='de'}<v lang=de>Und heisse zu Dir kommen mich,</v>{/if}
{#if urlLang=='en'}<v lang=en>And bid me come unto Thee</v>{/if}
{#if showLatin}<v lang=la>Ut cum Sanctis tuis laudem te</v>{/if}
{#if urlLang=='de'}<v lang=de>Damit ich möge loben Dich</v>{/if}
@@ -38,7 +38,7 @@
{#if urlLang === 'de'}<v lang="de">aufgefahren in den Himmel,</v>{/if}
{#if urlLang === 'en'}<v lang="en">He ascended into heaven,</v>{/if}
{#if showLatin}<v lang="la">sedet ad déxteram Dei Patris omnipoténtis,</v>{/if}
{#if urlLang === 'de'}<v lang="de">er sitzet zur Rechten Gottes, des allmächtigen Vaters;</v>{/if}
{#if urlLang === 'de'}<v lang="de">er sitzt zur Rechten Gottes, des allmächtigen Vaters;</v>{/if}
{#if urlLang === 'en'}<v lang="en">and sits at the right hand of God the Father almighty.</v>{/if}
{#if showLatin}<v lang="la">inde ventúrus est iudicáre vivos et mórtuos.</v>{/if}
{#if urlLang === 'de'}<v lang="de">von dort wird er kommen, zu richten die Lebenden und die Toten.</v>{/if}
@@ -23,10 +23,10 @@
{#if showLatin}<v lang="la">ómnibus Sanctis, et tibi pater:</v>{/if}
{#if urlLang === 'de'}<v lang="de">allen Heiligen und dir, Vater,</v>{/if}
{#if urlLang === 'en'}<v lang="en">to all the Saints, and to you, Father,</v>{/if}
{#if showLatin}<v lang="la">quia paccávi nimis</v>{/if}
{#if showLatin}<v lang="la">quia peccávi nimis</v>{/if}
{#if urlLang === 'de'}<v lang="de">dass ich viel gesündigt habe</v>{/if}
{#if urlLang === 'en'}<v lang="en">that I have sinned exceedingly</v>{/if}
{#if showLatin}<v lang="la">cogitatióne, verbe et ópere:</v>{/if}
{#if showLatin}<v lang="la">cogitatióne, verbo et ópere:</v>{/if}
{#if urlLang === 'de'}<v lang="de">in Gedanken, Worten und Werken,</v>{/if}
{#if urlLang === 'en'}<v lang="en">in thought, word, and deed:</v>{/if}
{#if showLatin}<v lang="la">mea culpa, mea culpa, mea máxima culpa.</v>{/if}
@@ -39,7 +39,7 @@
{#if urlLang === 'de'}<v lang="de">den hl. Erzengel Michael,</v>{/if}
{#if urlLang === 'en'}<v lang="en">blessed Michael the Archangel,</v>{/if}
{#if showLatin}<v lang="la">beátum Ioánnem Baptístam,</v>{/if}
{#if urlLang === 'de'}<v lang="de">dem hl. Johannes den Täufer,</v>{/if}
{#if urlLang === 'de'}<v lang="de">den hl. Johannes den Täufer,</v>{/if}
{#if urlLang === 'en'}<v lang="en">blessed John the Baptist,</v>{/if}
{#if showLatin}<v lang="la">sanctos Apóstolos Petrum et Paulum,</v>{/if}
{#if urlLang === 'de'}<v lang="de">die hll. Apostel Petrus und Paulus,</v>{/if}
@@ -76,7 +76,7 @@
{#if showLatin}<v lang="la">secúndum Scriptúras.</v>{/if}
{#if urlLang === 'de'}<v lang="de">gemäss der Schrift;</v>{/if}
{#if urlLang === 'en'}<v lang="en">in accordance with the Scriptures.</v>{/if}
{#if showLatin}<v lang="la">Et ascéndit in cáelum:</v>{/if}
{#if showLatin}<v lang="la">Et ascéndit in cælum:</v>{/if}
{#if urlLang === 'de'}<v lang="de">Er ist aufgefahren in den Himmel</v>{/if}
{#if urlLang === 'en'}<v lang="en">He ascended into heaven</v>{/if}
{#if showLatin}<v lang="la">sedet ad déxteram Patris.</v>{/if}
@@ -91,7 +91,7 @@
{#if urlLang === 'de'}<v lang="de">Gericht zu halten über Lebende und Tote:</v>{/if}
{#if urlLang === 'en'}<v lang="en">to judge the living and the dead</v>{/if}
{#if showLatin}<v lang="la">cujus regni non erit finis.</v>{/if}
{#if urlLang === 'de'}<v lang="de">und Seines Reiches wird kein Endes sein.</v>{/if}
{#if urlLang === 'de'}<v lang="de">und Seines Reiches wird kein Ende sein.</v>{/if}
{#if urlLang === 'en'}<v lang="en">and His kingdom will have no end.</v>{/if}
</p>
<p>
@@ -22,7 +22,7 @@
{#if urlLang === 'de'}<v lang="de">Ehre sei <i><sup></sup></i> Gott in der Höhe.</v>{/if}
{#if urlLang === 'en'}<v lang="en">Glory to <i><sup></sup></i> God in the highest.</v>{/if}
{#if showLatin}<v lang="la">Et in terra pax homínibus</v>{/if}
{#if urlLang === 'de'}<v lang="de">Und auf Erden Friede den Mesnchen,</v>{/if}
{#if urlLang === 'de'}<v lang="de">Und auf Erden Friede den Menschen,</v>{/if}
{#if urlLang === 'en'}<v lang="en">And on earth peace to men</v>{/if}
{#if showLatin}<v lang="la">bonæ voluntátis.</v>{/if}
{#if urlLang === 'de'}<v lang="de">die guten Willens sind.</v>{/if}
@@ -67,9 +67,9 @@
{#if urlLang === 'de'}<v lang="de">erbarme Dich unser.</v>{/if}
{#if urlLang === 'en'}<v lang="en">have mercy on us.</v>{/if}
{#if showLatin}<v lang="la">Qui tollis peccáta mundi,</v>{/if}
{#if urlLang === 'de'}<v lang="de">Du nimmst hinwerg die Sünden der Welt.</v>{/if}
{#if urlLang === 'de'}<v lang="de">Du nimmst hinweg die Sünden der Welt.</v>{/if}
{#if urlLang === 'en'}<v lang="en">Thou who takest away the sins of the world,</v>{/if}
{#if showLatin}<v lang="la"><i><sup></sup></i> súscipe depreciatiónem nostram.</v>{/if}
{#if showLatin}<v lang="la"><i><sup></sup></i> súscipe deprecatiónem nostram.</v>{/if}
{#if urlLang === 'de'}<v lang="de"><i><sup></sup></i> nimm unser Flehen gnädig auf.</v>{/if}
{#if urlLang === 'en'}<v lang="en"><i><sup></sup></i> receive our prayer.</v>{/if}
{#if showLatin}<v lang="la">Qui sedes ad déxteram Patris,</v>{/if}
@@ -11,7 +11,7 @@
{#if showLatin}<v lang="la">defénde nos in proélio,</v>{/if}
{#if urlLang === 'de'}<v lang="de">verteidige uns im Kampfe!</v>{/if}
{#if urlLang === 'en'}<v lang="en">defend us in battle.</v>{/if}
{#if showLatin}<v lang="la">cóntra nequítam et insídias</v>{/if}
{#if showLatin}<v lang="la">cóntra nequítiam et insídias</v>{/if}
{#if urlLang === 'de'}<v lang="de">Gegen die Bosheit und Nachstellungen</v>{/if}
{#if urlLang === 'en'}<v lang="en">Be our protection against the wickedness</v>{/if}
{#if showLatin}<v lang="la">diáboli ésto præsídium.</v>{/if}
@@ -20,7 +20,7 @@
{#if showLatin}<v lang="la">Ímperet ílli Déus, súpplices deprecámur:</v>{/if}
{#if urlLang === 'de'}<v lang="de">»Gott gebiete ihm!«, so bitten wir flehentlich.</v>{/if}
{#if urlLang === 'en'}<v lang="en">May God rebuke him, we humbly pray;</v>{/if}
{#if showLatin}<v lang="la">tuque, Prínceps milítæ cæléstis,</v>{/if}
{#if showLatin}<v lang="la">tuque, Prínceps milítiæ cæléstis,</v>{/if}
{#if urlLang === 'de'}<v lang="de">Du aber, Fürst der himmlischen Heerscharen,</v>{/if}
{#if urlLang === 'en'}<v lang="en">and do thou, O Prince of the heavenly host,</v>{/if}
{#if showLatin}<v lang="la">Sátanam aliósque spíritus malígnos,</v>{/if}
@@ -17,8 +17,8 @@
{#if showLatin}<v lang=la>ac máximo ánimi ardóre te oro atque obtéstor, </v>{/if}
{#if urlLang=='de'}<v lang=de>Inbrünstig bitte und beschwöre ich Dich:</v>{/if}
{#if urlLang=='en'}<v lang=en>and with the most fervent desire of my soul I pray and beseech Thee</v>{/if}
{#if showLatin}<v lang=la> ut meum in cor vívidos fídei, spei et caritátis sensus, atque veram peccatórum meórum pœniténtiam,</v>{/if}
{#if urlLang=='de'}<v lang=de>Präge meinem Herzen lebendige Gefühle des Glaubens, der Hoffung und der Liebe ein</v>{/if}
{#if showLatin}<v lang=la> ut in cor meum vívidos fídei, spei et caritátis sensus, atque veram peccatórum meórum pœniténtiam,</v>{/if}
{#if urlLang=='de'}<v lang=de>Präge meinem Herzen lebendige Gefühle des Glaubens, der Hoffnung und der Liebe ein</v>{/if}
{#if urlLang=='en'}<v lang=en>to impress upon my heart lively sentiments of faith, hope and charity,</v>{/if}
{#if showLatin} <v lang=la>éaque emendándi firmíssimam voluntátem velis</v>{/if}
{#if urlLang=='de'}<v lang=de>sowie wahre Reue über meine Sünden und den ganz festen Willen, mich zu bessern.</v>{/if}
@@ -29,9 +29,9 @@
{#if showLatin}<v lang=la>illud præ óculis habens, quod jam in ore ponébat tuo David Prophéta de te, o bone Jesu:</v>{/if}
{#if urlLang=='de'}<v lang=de>Dabei halte ich mir vor Augen, was im Hinblick auf Dich, o guter Jesus, schon der Prophet David Dir in den Mund legte:</v>{/if}
{#if urlLang=='en'}<v lang=en>having before mine eyes that which David, the prophet, long ago spoke in Thine Own person concerning Thee, my Jesus:</v>{/if}
{#if showLatin}<v lang=la> «Fodérunt manus meas et pedes meos; dinumeravérunt ómnio ossa mea.» (Ps. 21, 17-18)</v>{/if}
{#if showLatin}<v lang=la> «Fodérunt manus meas et pedes meos; dinumeravérunt ómnia ossa mea.» (Ps. 21, 17-18)</v>{/if}
{#if urlLang=='de'}<v lang=de>«Sie haben Meine Hände und Meine Füsse durchbohrt; alle meine Gebeine haben sie gezählt.» (Ps. 21, 17-18)</v>{/if}
{#if urlLang=='en'}<v lang=en>"They have pierced My hands and My feet, they have numbered all My bones." (Ps. 21:17-18</v>{/if}
{#if urlLang=='en'}<v lang=en>"They have pierced My hands and My feet, they have numbered all My bones." (Ps. 21:17-18)</v>{/if}
<v lang=und>Amen.</v>
</p>
{/snippet}
@@ -23,7 +23,7 @@
{#if urlLang === 'de'}<v lang="de">verleihe uns, wir bitten dich,</v>{/if}
{#if urlLang === 'en'}<v lang="en">grant, we beseech Thee,</v>{/if}
{#if showLatin}<v lang="la">út, hæc mystéria sanctíssimo beátæ Maríæ Vírginis Rosário recoléntes;</v>{/if}
{#if urlLang === 'de'}<v lang="de">dass wir, indem wir die Geheimisse des heiligen Rosenkranzes der allerseligsten Jungfrau ehren,</v>{/if}
{#if urlLang === 'de'}<v lang="de">dass wir, indem wir die Geheimnisse des heiligen Rosenkranzes der allerseligsten Jungfrau ehren,</v>{/if}
{#if urlLang === 'en'}<v lang="en">that by meditating on these mysteries of the most holy Rosary of the Blessed Virgin Mary,</v>{/if}
{#if showLatin}<v lang="la">ét imitémur quód cóntinent,</v>{/if}
{#if urlLang === 'de'}<v lang="de">was sie enthalten nachahmen</v>{/if}
@@ -0,0 +1,89 @@
<script>
import RestTimer from './RestTimer.svelte';
import ExerciseName from './ExerciseName.svelte';
import { page } from '$app/state';
import { detectFitnessLang } from '$lib/js/fitnessI18n';
/**
* @type {{
* active: boolean,
* seconds: number,
* total: number,
* exerciseId?: string | null,
* setIdx: number,
* activeExerciseIdx: number,
* restExerciseIdx: number,
* onAdjust?: ((delta: number) => void) | null,
* onSkip?: (() => void) | null
* }}
*/
let {
active,
seconds,
total,
exerciseId = null,
setIdx,
activeExerciseIdx,
restExerciseIdx,
onAdjust = null,
onSkip = null
} = $props();
const lang = $derived(detectFitnessLang(page.url.pathname));
const isEn = $derived(lang === 'en');
const isOtherExercise = $derived(restExerciseIdx >= 0 && restExerciseIdx !== activeExerciseIdx);
const setLabel = $derived(setIdx >= 0
? (isEn ? `Rest · Set ${setIdx + 1}` : `Pause · Satz ${setIdx + 1}`)
: (isEn ? 'Rest' : 'Pause'));
</script>
{#if active && total > 0}
<section class="active-rest" aria-live="polite">
<header class="rest-context">
<span class="rest-label">{setLabel}</span>
{#if isOtherExercise && exerciseId}
<span class="rest-sep" aria-hidden="true">·</span>
<span class="rest-exercise"><ExerciseName {exerciseId} plain /></span>
{/if}
</header>
<RestTimer
{seconds}
{total}
onComplete={onSkip}
{onAdjust}
{onSkip}
/>
</section>
{/if}
<style>
.active-rest {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.rest-context {
display: flex;
align-items: baseline;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-text-tertiary);
}
.rest-label {
color: var(--blue);
}
.rest-sep {
color: var(--color-text-tertiary);
}
.rest-exercise {
color: var(--color-text-secondary);
text-transform: none;
letter-spacing: 0;
font-weight: 600;
font-size: 0.78rem;
}
</style>
+101 -37
View File
@@ -200,47 +200,107 @@
}
// Generate future predicted cycles (12 cycles ≈ ~1 year)
const cycleMs = Math.round(emaCycle) * 86400000;
const meanCycleDays = Math.round(emaCycle);
const cycleMs = meanCycleDays * 86400000;
const periodMs = (Math.round(emaPeriod) - 1) * 86400000;
const lutealLength = 14;
const lastStart = sorted[0] ? new Date(sorted[0].startDate) : null;
/** @type {{ start: Date, end: Date, fertileStart: Date, fertileEnd: Date, peakStart: Date, lutealStart: Date, lutealEnd: Date }[]} */
// Cycle range for Ogino-style widening of future fertile windows.
// Without ≥2 observed cycles, widening collapses to a point estimate.
const shortestCycle = cycleLengths.length >= 2 ? Math.min(...cycleLengths) : meanCycleDays;
const longestCycle = cycleLengths.length >= 2 ? Math.max(...cycleLengths) : meanCycleDays;
/**
* Build a fertility window for one cycle.
*
* Anchor: the next period's start (luteal-back-count). Past cycles know it
* exactly; future cycles use the mean prediction and widen the outer fertile
* range to cover the earliest/latest historically observed ovulation day.
*
* Floor: fertile/peak never overlap the prior bleed. Day-after-period-end
* is the earliest possible fertile day shown — a hard biological floor for
* the user's mental model, even though sperm survival could in theory begin
* earlier in the bleed for very short cycles.
*
* @param {number} cycleStartMs ms of cycle start (= prior period start)
* @param {number | null} priorPeriodEndMs ms of prior bleed end, or null if unknown
* @param {number} nextPeriodStartMs ms of the next period's start
* @param {boolean} widen true → use shortest/longest cycle bounds; false → point estimate
*/
function buildWindow(cycleStartMs, priorPeriodEndMs, nextPeriodStartMs, widen) {
const ovMs = nextPeriodStartMs - lutealLength * 86400000;
const earliestOvMs = widen
? cycleStartMs + (shortestCycle - lutealLength) * 86400000
: ovMs;
let latestOvMs = widen
? cycleStartMs + (longestCycle - lutealLength) * 86400000
: ovMs;
// Cap latest ov before the next bleed starts.
if (latestOvMs > nextPeriodStartMs - 86400000) latestOvMs = nextPeriodStartMs - 86400000;
const floorMs = priorPeriodEndMs !== null ? priorPeriodEndMs + 86400000 : cycleStartMs;
let fertileStartMs = Math.max(earliestOvMs - 5 * 86400000, floorMs, cycleStartMs);
let peakStartMs = Math.max(ovMs - 2 * 86400000, floorMs, cycleStartMs);
const peakEndMs = ovMs - 86400000;
let fertileEndMs = Math.max(latestOvMs, ovMs);
// Suppress peak if floor pushed it past ov (e.g. very short cycle + long period).
if (peakStartMs > peakEndMs) peakStartMs = peakEndMs + 86400000;
// Keep fertile envelope around peak/ov.
if (fertileStartMs > peakStartMs && peakStartMs <= peakEndMs) fertileStartMs = peakStartMs;
return {
fertileStart: new Date(fertileStartMs),
fertileEnd: new Date(fertileEndMs),
peakStart: new Date(peakStartMs),
peakEnd: new Date(peakEndMs),
ovulation: new Date(ovMs),
lutealStart: new Date(latestOvMs + 86400000),
lutealEnd: new Date(nextPeriodStartMs - 86400000)
};
}
/** @type {{ start: Date, end: Date, fertileStart: Date, fertileEnd: Date, peakStart: Date, peakEnd: Date, ovulation: Date, lutealStart: Date, lutealEnd: Date }[]} */
const futureCycles = [];
if (lastStart) {
let base = lastStart.getTime();
// Prior bleed end for the first predicted cycle: actual end if recorded,
// predicted end if ongoing, else cycle start.
let priorPeriodEndMs;
if (sorted[0]?.endDate) {
priorPeriodEndMs = midnight(new Date(sorted[0].endDate));
} else if (predictedEndOfOngoing) {
priorPeriodEndMs = midnight(predictedEndOfOngoing);
} else {
priorPeriodEndMs = base;
}
for (let i = 0; i < 12; i++) {
const start = new Date(base + cycleMs);
const end = new Date(start.getTime() + periodMs);
const ov = new Date(start.getTime() - lutealLength * 86400000);
// Luteal phase: day after ovulation until day before next period
const lutealStart = new Date(ov.getTime() + 86400000);
const lutealEnd = new Date(start.getTime() - 86400000);
const nextPeriodStartMs = base + cycleMs;
const periodEndMs = nextPeriodStartMs + periodMs;
const w = buildWindow(base, priorPeriodEndMs, nextPeriodStartMs, /* widen */ true);
futureCycles.push({
start, end,
fertileStart: new Date(ov.getTime() - 5 * 86400000),
fertileEnd: ov,
peakStart: new Date(ov.getTime() - 2 * 86400000),
lutealStart,
lutealEnd
start: new Date(nextPeriodStartMs),
end: new Date(periodEndMs),
...w
});
base = start.getTime();
base = nextPeriodStartMs;
priorPeriodEndMs = periodEndMs;
}
}
// Past fertility/luteal windows (from completed cycles)
/** @type {{ fertileStart: Date, fertileEnd: Date, peakStart: Date, lutealStart: Date, lutealEnd: Date }[]} */
/** @type {{ fertileStart: Date, fertileEnd: Date, peakStart: Date, peakEnd: Date, ovulation: Date, lutealStart: Date, lutealEnd: Date }[]} */
const pastFertileWindows = [];
for (let i = 1; i < completed.length; i++) {
const nextPeriodStart = new Date(completed[i].startDate);
const ov = new Date(nextPeriodStart.getTime() - lutealLength * 86400000);
pastFertileWindows.push({
fertileStart: new Date(ov.getTime() - 5 * 86400000),
fertileEnd: ov,
peakStart: new Date(ov.getTime() - 2 * 86400000),
lutealStart: new Date(ov.getTime() + 86400000),
lutealEnd: new Date(nextPeriodStart.getTime() - 86400000)
});
const cycleStartMs = midnight(new Date(completed[i - 1].startDate));
const priorPeriodEndMs = completed[i - 1].endDate
? midnight(new Date(completed[i - 1].endDate))
: null;
const nextPeriodStartMs = midnight(new Date(completed[i].startDate));
pastFertileWindows.push(buildWindow(cycleStartMs, priorPeriodEndMs, nextPeriodStartMs, /* widen */ false));
}
return {
@@ -405,12 +465,14 @@
const cs = midnight(c.start);
const ce = midnight(c.end);
if (d >= cs && d <= ce) return 'predicted';
const fe = midnight(c.fertileEnd);
if (d === fe) return 'ovulation';
const ovDay = midnight(c.ovulation);
if (d === ovDay) return 'ovulation';
const ps = midnight(c.peakStart);
const pe = midnight(c.peakEnd);
if (d >= ps && d <= pe) return 'peak-fertile';
const fs = midnight(c.fertileStart);
if (d >= ps && d < fe) return 'peak-fertile';
if (d >= fs && d < ps) return 'fertile';
const fe = midnight(c.fertileEnd);
if (d >= fs && d <= fe) return 'fertile';
const ls = midnight(c.lutealStart);
const le = midnight(c.lutealEnd);
if (d >= ls && d <= le) return 'luteal';
@@ -418,12 +480,14 @@
// Past fertility/luteal windows
for (const w of predictions.pastFertileWindows) {
const fe = midnight(w.fertileEnd);
if (d === fe) return 'ovulation';
const ovDay = midnight(w.ovulation);
if (d === ovDay) return 'ovulation';
const ps = midnight(w.peakStart);
const pe = midnight(w.peakEnd);
if (d >= ps && d <= pe) return 'peak-fertile';
const fs = midnight(w.fertileStart);
if (d >= ps && d < fe) return 'peak-fertile';
if (d >= fs && d < ps) return 'fertile';
const fe = midnight(w.fertileEnd);
if (d >= fs && d <= fe) return 'fertile';
const ls = midnight(w.lutealStart);
const le = midnight(w.lutealEnd);
if (d >= ls && d <= le) return 'luteal';
@@ -737,8 +801,8 @@
<div class="status-side">
<div class="status-side-item ovulation-accent">
<span class="status-side-label">{t.ovulation}</span>
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
<span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
<span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
</div>
<div class="status-side-item fertile-accent">
<span class="status-side-label">{t.fertile}</span>
@@ -762,8 +826,8 @@
<div class="status-side">
<div class="status-side-item ovulation-accent">
<span class="status-side-label">{t.ovulation}</span>
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
<span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
<span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
</div>
<div class="status-side-item fertile-accent">
<span class="status-side-label">{t.fertile}</span>
+47 -6
View File
@@ -8,10 +8,12 @@
import Route from '@lucide/svelte/icons/route';
import Gauge from '@lucide/svelte/icons/gauge';
import Flame from '@lucide/svelte/icons/flame';
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
import CloudOff from '@lucide/svelte/icons/cloud-off';
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang(page.url.pathname));
const sl = $derived(fitnessSlugs(lang));
const t = $derived(m[lang]);
/**
* @type {{
@@ -30,10 +32,11 @@
* gpsPreview?: number[][],
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: number }>
* }>
* }
* },
* unsynced?: boolean
* }}
*/
let { session } = $props();
let { session, unsynced = false } = $props();
/** @param {number} mins */
function formatDuration(mins) {
@@ -153,9 +156,17 @@
});
</script>
<a href={resolve('/fitness/[history=fitnessHistory]/[id]', { history: sl.history, id: session._id })} class="session-card">
{#snippet cardBody()}
<div class="card-top">
<h3 class="session-name">{session.name}</h3>
<h3 class="session-name">
{session.name}
{#if unsynced}
<span class="unsynced-badge" title={t.unsynced_label}>
<CloudOff size={12} strokeWidth={2} />
{t.unsynced_label}
</span>
{/if}
</h3>
<span class="session-date">{formatDate(session.startTime)} &middot; {formatTime(session.startTime)}</span>
</div>
@@ -207,7 +218,13 @@
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
{/if}
</div>
</a>
{/snippet}
{#if unsynced}
<div class="session-card unsynced">{@render cardBody()}</div>
{:else}
<a href={resolve('/fitness/[history=fitnessHistory]/[id]', { history: sl.history, id: session._id })} class="session-card">{@render cardBody()}</a>
{/if}
<style>
.session-card {
@@ -228,6 +245,30 @@
.session-card:active {
transform: translateY(0);
}
.session-card.unsynced {
border-left: 3px solid var(--orange, var(--nord12));
opacity: 0.92;
cursor: default;
}
.session-card.unsynced:hover {
transform: none;
box-shadow: var(--shadow-sm);
}
.unsynced-badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--orange, var(--nord12));
background: color-mix(in srgb, var(--orange, var(--nord12)) 12%, transparent);
border-radius: 100px;
vertical-align: middle;
}
.card-top {
margin-bottom: 0.6rem;
}
@@ -4,7 +4,6 @@
import Play from '@lucide/svelte/icons/play';
import Square from '@lucide/svelte/icons/square';
import { METRIC_LABELS } from '$lib/data/exercises';
import RestTimer from './RestTimer.svelte';
import { page } from '$app/state';
import { detectFitnessLang, m } from '$lib/js/fitnessI18n';
@@ -17,14 +16,9 @@
* previousSets?: Array<Record<string, any>> | null,
* metrics?: Array<'weight' | 'reps' | 'rpe' | 'distance' | 'duration'>,
* editable?: boolean,
* restAfterSet?: number,
* restSeconds?: number,
* restTotal?: number,
* holdAfterSet?: number,
* holdSeconds?: number,
* holdTotal?: number,
* onRestAdjust?: ((delta: number) => void) | null,
* onRestSkip?: (() => void) | null,
* timedHold?: boolean,
* onHoldSkip?: (() => void) | null,
* onUpdate?: ((setIndex: number, data: Record<string, number | null>) => void) | null,
@@ -37,15 +31,10 @@
previousSets = null,
metrics = ['weight', 'reps', 'rpe'],
editable = false,
restAfterSet = -1,
restSeconds = 0,
restTotal = 0,
timedHold = false,
holdAfterSet = -1,
holdSeconds = 0,
holdTotal = 0,
onRestAdjust = null,
onRestSkip = null,
onHoldSkip = null,
onUpdate = null,
onToggleComplete = null,
@@ -215,19 +204,6 @@
</td>
</tr>
{/if}
{#if restAfterSet === i && restTotal > 0}
<tr class="rest-row">
<td colspan={totalCols} class="rest-cell">
<RestTimer
seconds={restSeconds}
total={restTotal}
onComplete={onRestSkip}
onAdjust={onRestAdjust}
onSkip={onRestSkip}
/>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
@@ -79,6 +79,7 @@
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
view-transition-name: workout-focus-card;
}
/* Eyebrow row: step counter + bodypart + equipment, controls on the right */
@@ -0,0 +1,264 @@
<script lang="ts">
// Typeahead chip selector — a text field that opens a dropdown of matching
// options, with the picked ones shown below as removable chips. Generic over
// the value: used for free-text tags (with a leading "#") and for cantons
// (with the coat-of-arms emblem rendered before the name). Themed with the
// semantic variables so it fits the filter panel in both colour schemes.
import type { SvelteSet } from 'svelte/reactivity';
import X from '@lucide/svelte/icons/x';
interface Props {
/** All selectable values, in display order. */
options: string[];
/** Currently-selected values. Mutated via {@link onToggle}. */
selected: SvelteSet<string>;
onToggle: (value: string) => void;
placeholder?: string;
/** Prefix each value with a "#" (tag style). */
hash?: boolean;
/** Optional icon URL rendered before each value (e.g. canton emblem). */
iconFor?: (value: string) => string | undefined;
/** Display label for a value (defaults to the value itself). */
labelFor?: (value: string) => string;
}
const {
options,
selected,
onToggle,
placeholder = 'Eingeben oder auswählen…',
hash = false,
iconFor,
labelFor = (v) => v
}: Props = $props();
// Unique per instance — two of these live in the panel at once (tags +
// cantons), so a shared id would be a duplicate.
const dropdownId = `tt-dd-${Math.random().toString(36).slice(2, 9)}`;
let inputValue = $state('');
let open = $state(false);
let wrapper = $state<HTMLElement>();
let inputEl = $state<HTMLInputElement>();
const unselected = $derived(options.filter((v) => !selected.has(v)));
const filtered = $derived.by(() => {
const q = inputValue.trim().toLowerCase();
if (q === '') return unselected;
return unselected.filter(
(v) => labelFor(v).toLowerCase().includes(q) || v.toLowerCase().includes(q)
);
});
// Selected values kept in the canonical display order rather than click order.
const selectedList = $derived(options.filter((v) => selected.has(v)));
function pick(value: string) {
onToggle(value);
inputValue = '';
// Keep the field focused so several can be added in a row.
inputEl?.focus();
open = true;
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
const q = inputValue.trim().toLowerCase();
const match =
filtered.find((v) => labelFor(v).toLowerCase() === q || v.toLowerCase() === q) ??
filtered[0];
if (match) pick(match);
} else if (e.key === 'Escape') {
if (inputValue) {
inputValue = '';
} else {
open = false;
inputEl?.blur();
}
}
}
// Close when focus leaves the whole widget (click-away / tab-out), but stay
// open while moving between the input and its dropdown chips.
function onFocusOut(e: FocusEvent) {
const next = e.relatedTarget as Node | null;
if (!next || !wrapper?.contains(next)) open = false;
}
</script>
<div class="tt" bind:this={wrapper} onfocusout={onFocusOut}>
<div class="tt-field">
<input
class="tt-input"
type="text"
bind:this={inputEl}
bind:value={inputValue}
onfocus={() => (open = true)}
onkeydown={onKey}
{placeholder}
autocomplete="off"
role="combobox"
aria-expanded={open}
aria-controls={dropdownId}
/>
{#if open && filtered.length > 0}
<div class="tt-dropdown" id={dropdownId}>
{#each filtered as value (value)}
{@const icon = iconFor?.(value)}
<button type="button" class="tt-option" onclick={() => pick(value)}>
{#if icon}<img class="tt-emblem" src={icon} alt="" aria-hidden="true" />{/if}
{#if hash}<span class="tt-hash" aria-hidden="true">#</span>{/if}
{labelFor(value)}
</button>
{/each}
</div>
{/if}
</div>
{#if selectedList.length > 0}
<div class="tt-selected">
{#each selectedList as value (value)}
{@const icon = iconFor?.(value)}
<button
type="button"
class="tt-chip"
onclick={() => onToggle(value)}
aria-label={`${labelFor(value)} entfernen`}
>
{#if icon}<img class="tt-emblem" src={icon} alt="" aria-hidden="true" />{/if}
{#if hash}<span class="tt-hash" aria-hidden="true">#</span>{/if}
{labelFor(value)}
<X size={13} strokeWidth={2} aria-hidden="true" />
</button>
{/each}
</div>
{/if}
</div>
<style>
.tt {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tt-field {
position: relative;
}
.tt-input {
box-sizing: border-box;
width: 100%;
font: inherit;
font-size: 0.85rem;
padding: 0.5rem 0.7rem;
color: var(--color-text-primary);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: border-color var(--transition-fast);
}
.tt-input::placeholder {
color: var(--color-text-tertiary);
}
.tt-input:focus-visible {
outline: none;
border-color: var(--color-primary);
}
.tt-dropdown {
position: absolute;
top: calc(100% + 0.3rem);
left: 0;
right: 0;
z-index: 20;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
max-height: 200px;
overflow-y: auto;
padding: 0.5rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.tt-option {
display: inline-flex;
align-items: center;
gap: 0.3rem;
appearance: none;
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.7rem;
border-radius: var(--radius-pill);
cursor: pointer;
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
transition: scale var(--transition-fast), background-color var(--transition-fast),
color var(--transition-fast);
}
.tt-option:hover {
scale: 1.05;
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.tt-selected {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.tt-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
appearance: none;
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.5rem 0.25rem 0.7rem;
border-radius: var(--radius-pill);
cursor: pointer;
color: var(--color-text-on-primary);
background: var(--color-primary);
border: 1px solid var(--color-primary);
transition: scale var(--transition-fast), background-color var(--transition-fast);
}
.tt-chip:hover {
scale: 1.05;
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
.tt-chip :global(svg) {
opacity: 0.85;
}
/* Canton coat-of-arms — tall shield, kept proportional in a fixed slot. */
.tt-emblem {
width: 13px;
height: 16px;
object-fit: contain;
flex: 0 0 auto;
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.18));
}
.tt-hash {
opacity: 0.6;
font-weight: 600;
}
.tt-chip .tt-hash {
opacity: 0.85;
}
</style>
@@ -0,0 +1,587 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Chart as ChartType } from 'chart.js';
import type { HikeTrackPoint } from '$types/hikes';
import { hover, setHover, clearHover } from './hoverStore.svelte';
interface Props {
track: HikeTrackPoint[];
/** Restrict the x-axis to a stage's index range (multi-day hikes). The
* dataset stays the full track so hover indices remain global. */
viewRange?: { startIdx: number; endIdx: number } | null;
}
const { track, viewRange = null }: Props = $props();
// x-axis window in km for the current view (whole track, or a stage slice).
function xBounds(): { min: number; max: number } {
const last = cumKm[cumKm.length - 1] ?? 0;
if (!viewRange) return { min: 0, max: last };
const lo = Math.max(0, Math.min(viewRange.startIdx, cumKm.length - 1));
const hi = Math.max(0, Math.min(viewRange.endIdx, cumKm.length - 1));
return { min: cumKm[lo] ?? 0, max: cumKm[hi] ?? last };
}
let canvas = $state<HTMLCanvasElement | undefined>(undefined);
let chart: ChartType | null = null;
let ChartCtor: typeof import('chart.js').Chart | null = null;
// Goes true once Chart.js has painted at least one frame. Drives the
// cross-fade from the SSR-rendered static SVG to the interactive canvas.
// Stays sticky-true on theme re-creation so the SVG doesn't flash back.
let chartReady = $state(false);
// Cumulative distance (km) per track point — used as x axis.
const cumKm = $derived.by(() => {
const out = new Array<number>(track.length);
out[0] = 0;
const R = 6371;
for (let i = 1; i < track.length; i++) {
const a = track[i - 1];
const b = track[i];
const dLat = ((b[1] - a[1]) * Math.PI) / 180;
const dLng = ((b[0] - a[0]) * Math.PI) / 180;
const sinLat = Math.sin(dLat / 2);
const sinLng = Math.sin(dLng / 2);
const h =
sinLat * sinLat +
Math.cos((a[1] * Math.PI) / 180) * Math.cos((b[1] * Math.PI) / 180) * sinLng * sinLng;
out[i] = out[i - 1] + 2 * R * Math.asin(Math.sqrt(h));
}
return out;
});
// SSR-rendered fallback: a static SVG profile of the whole track so no-JS
// (and pre-hydration) users see the elevation graph immediately. Path
// coordinates live in an 800×200 viewBox; `preserveAspectRatio="none"`
// stretches the path to fill the box on any aspect ratio. Strokes use
// `vector-effect: non-scaling-stroke` so the line weight stays at a
// constant pixel weight regardless of the stretch. Once Chart.js mounts
// and paints, the SVG fades out and the interactive canvas takes over.
const FALLBACK_VB_W = 800;
const FALLBACK_VB_H = 200;
const elevFallback = $derived.by(() => {
if (track.length < 2) return { fill: '', line: '' };
// Per-track sample cap so a ~5 000-point GPX doesn't produce a 60 KB
// SVG path in the HTML. ~600 samples is enough for a smooth profile
// at typical display widths and keeps the inline SVG around ~6 KB.
const target = 600;
const step = Math.max(1, Math.floor(track.length / target));
let altLo = Infinity;
let altHi = -Infinity;
for (let i = 0; i < track.length; i++) {
const a = track[i][2];
if (typeof a === 'number') {
if (a < altLo) altLo = a;
if (a > altHi) altHi = a;
}
}
if (!Number.isFinite(altLo)) return { fill: '', line: '' };
const maxKm = cumKm[cumKm.length - 1];
if (!maxKm) return { fill: '', line: '' };
// No vertical pad: the path needs to touch the top/bottom of the
// plot exactly, otherwise the HTML axis labels (min/max altitude)
// drawn next to the SVG won't line up with the actual peak/trough.
const yMin = altLo;
const ySpread = altHi - altLo || 1;
let line = '';
let firstX: number | null = null;
let lastX: number | null = null;
const append = (i: number) => {
const a = track[i][2];
if (typeof a !== 'number') return;
const x = (cumKm[i] / maxKm) * FALLBACK_VB_W;
const y = (1 - (a - yMin) / ySpread) * FALLBACK_VB_H;
if (firstX === null) {
firstX = x;
line = `M${x.toFixed(1)} ${y.toFixed(1)}`;
} else {
line += `L${x.toFixed(1)} ${y.toFixed(1)}`;
}
lastX = x;
};
for (let i = 0; i < track.length; i += step) append(i);
// Always include the last sample so the trace runs to maxKm.
if ((track.length - 1) % step !== 0) append(track.length - 1);
if (firstX === null || lastX === null) return { fill: '', line: '' };
const fill = `${line}L${lastX.toFixed(1)} ${FALLBACK_VB_H}L${firstX.toFixed(1)} ${FALLBACK_VB_H}Z`;
return { fill, line };
});
// Axis ticks for the SSR fallback: five values per axis (start, three
// intermediates, end) so each axis reads as a properly-scaled chart,
// not just labelled at the bookends. y-ticks are emitted top-to-bottom
// so the first label = max altitude, matching the SVG's y=0-at-top
// coordinate system. The three intermediate y-tick fractions (0.75,
// 0.5, 0.25) double as the soft helpline positions inside the plot,
// expressed as `viewBox` y-offsets below.
const elevFallbackKm = $derived(cumKm[cumKm.length - 1] ?? 0);
const elevFallbackYTicks = $derived.by(() => {
let lo = Infinity;
let hi = -Infinity;
for (let i = 0; i < track.length; i++) {
const a = track[i][2];
if (typeof a === 'number') {
if (a < lo) lo = a;
if (a > hi) hi = a;
}
}
if (!Number.isFinite(lo)) return null;
const min = Math.round(lo);
const max = Math.round(hi);
const span = max - min;
return [
max,
Math.round(min + span * 0.75),
Math.round(min + span * 0.5),
Math.round(min + span * 0.25),
min
];
});
const elevFallbackXTicks = $derived.by(() => {
const max = elevFallbackKm;
if (!max) return [];
return [0, max * 0.25, max * 0.5, max * 0.75, max];
});
// Horizontal helpline positions inside the SVG (viewBox y-coords).
// Only the three intermediates — the top and bottom of the plot are
// already framed by the filled area's edge, so adding gridlines there
// would just be visual noise.
const ELEV_FALLBACK_GRID_Y = [
FALLBACK_VB_H * 0.25,
FALLBACK_VB_H * 0.5,
FALLBACK_VB_H * 0.75
];
function isDark(): boolean {
const t = document.documentElement.getAttribute('data-theme');
if (t === 'dark') return true;
if (t === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function readPrimaryColor(): string {
const v = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim();
return v || '#5e81ac';
}
function hexToRgba(hex: string, alpha: number): string {
const h = hex.replace('#', '');
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function createChart() {
if (!canvas || !ChartCtor) return;
if (chart) chart.destroy();
const dark = isDark();
const textColor = dark ? '#D8DEE9' : '#2E3440';
const gridColor = dark ? 'rgba(216,222,233,0.1)' : 'rgba(46,52,64,0.08)';
const primary = readPrimaryColor();
const data = track.map((p, i) => ({ x: cumKm[i], y: typeof p[2] === 'number' ? p[2] : null }));
// Custom plugin: a dashed vertical guide line at the chart's
// active-element x. Works for both pointer-driven hovers and the
// externally-triggered setActiveElements path (map, scroll tracker)
// because both populate `chart.tooltip._active`.
const verticalLine = {
id: 'verticalCursor',
afterDatasetsDraw(c: ChartType) {
const active = c.tooltip?.getActiveElements?.() ?? [];
if (active.length === 0) return;
const x = (active[0].element as unknown as { x: number }).x;
const { top, bottom } = c.chartArea;
const ctx = c.ctx;
ctx.save();
ctx.beginPath();
ctx.moveTo(x, top);
ctx.lineTo(x, bottom);
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1;
ctx.strokeStyle = primary;
ctx.globalAlpha = 0.85;
ctx.stroke();
ctx.restore();
}
};
// Flip the SSR-fallback flag synchronously with chart creation so the
// next paint already shows the canvas underneath the fading SVG.
// Set inside `createChart` (not only in `onMount`) so theme rebuilds
// don't briefly flash the SVG back; the flag is one-way (never reset).
chartReady = true;
chart = new ChartCtor(canvas, {
type: 'line',
data: {
datasets: [
{
label: 'Höhe',
data,
borderColor: primary,
backgroundColor: hexToRgba(primary, 0.18),
borderWidth: 2,
pointRadius: 0,
// Active state needs to be visually obvious: a solid
// dot at the hovered index, with the dashed line from
// the custom plugin above carrying the eye down to the
// x-axis.
pointHoverRadius: 6,
pointHoverBackgroundColor: primary,
pointHoverBorderColor: '#fff',
pointHoverBorderWidth: 2,
tension: 0.2,
fill: 'origin',
spanGaps: true
}
]
},
plugins: [verticalLine],
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
displayColors: false,
callbacks: {
title: (items) => `${(items[0].parsed.x as number).toFixed(2)} km`,
label: (item) => `${Math.round(item.parsed.y as number)} m`
}
}
},
scales: {
x: {
type: 'linear',
// Pin the axis to the actual data range so Chart.js doesn't
// round up to the next nice tick — otherwise a 12.3 km hike
// ends up with empty space out to 14 km. When a stage is
// selected, the window narrows to that stage.
min: xBounds().min,
max: xBounds().max,
bounds: 'data',
title: { display: true, text: 'Distanz (km)', color: textColor },
ticks: { color: textColor },
grid: { color: gridColor }
},
y: {
title: { display: true, text: 'Höhe (m)', color: textColor },
ticks: { color: textColor },
grid: { color: gridColor }
}
},
onHover: (_evt, elements) => {
if (elements.length === 0) {
if (hover.source === 'chart') clearHover();
return;
}
setHover(elements[0].index, 'chart');
}
}
});
}
onMount(() => {
let disposed = false;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onTheme = () => setTimeout(createChart, 100);
let obs: MutationObserver | undefined;
(async () => {
const { Chart, registerables } = await import('chart.js');
if (disposed) return;
Chart.register(...registerables);
ChartCtor = Chart;
createChart();
mq.addEventListener('change', onTheme);
obs = new MutationObserver((muts) => {
for (const m of muts) if (m.attributeName === 'data-theme') onTheme();
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
})();
return () => {
disposed = true;
mq.removeEventListener('change', onTheme);
obs?.disconnect();
if (chart) chart.destroy();
};
});
// React to external hover (from the map, image markers, or the page-
// level scroll tracker) by painting the matching tooltip + cursor at
// the right pixel position.
//
// IMPORTANT: read both `hover.source` and `hover.index` at the very
// top of the effect so Svelte registers the subscription on the first
// run — even when the Chart.js instance isn't ready yet (the import is
// async). If we early-returned on `!chart` first, the hover reads
// would never happen and the effect would never re-run for external
// updates after the chart finally mounted.
$effect(() => {
const src = hover.source;
const idx = hover.index;
if (!chart) return;
if (src === 'chart') return;
if (idx === null) {
chart.setActiveElements([]);
chart.tooltip?.setActiveElements([], { x: 0, y: 0 });
chart.update('none');
return;
}
if (idx < 0 || idx >= track.length) return;
const datasetIndex = 0;
const meta = chart.getDatasetMeta(datasetIndex);
const elem = meta?.data?.[idx] as { x: number; y: number } | undefined;
const anchor = elem ? { x: elem.x, y: elem.y } : { x: 0, y: 0 };
chart.setActiveElements([{ datasetIndex, index: idx }]);
chart.tooltip?.setActiveElements([{ datasetIndex, index: idx }], anchor);
chart.update('none');
});
// Re-window the x-axis when the active stage changes (reads `viewRange`).
$effect(() => {
const b = (() => {
void viewRange;
return xBounds();
})();
if (!chart) return;
const xScale = chart.options.scales?.x;
if (!xScale) return;
xScale.min = b.min;
xScale.max = b.max;
chart.update('none');
});
// Rebuild the dataset when the track data itself changes — e.g. the route
// builder edits the route live. On the static detail page `track` is stable
// after its one-time fetch, so this runs once (no-op) and never again.
$effect(() => {
const pts = track;
const ck = cumKm;
if (!chart) return;
chart.data.datasets[0].data = pts.map((p, i) => ({
x: ck[i],
y: typeof p[2] === 'number' ? p[2] : null
}));
const b = xBounds();
const xScale = chart.options.scales?.x;
if (xScale) {
xScale.min = b.min;
xScale.max = b.max;
}
chart.update('none');
});
// Mouse-leave on the canvas clears the shared hover state so the map marker
// disappears too.
function onCanvasMouseLeave() {
if (hover.source === 'chart') clearHover();
}
</script>
<div class="elevation">
<!-- Static SVG profile rendered server-side so no-JS readers (and JS
users pre-hydration) see the elevation graph without waiting on
Chart.js. The grid lays out the axis gutters (y-title + y-ticks
on the left, x-ticks + x-title under) so the SVG plot occupies
the same content region Chart.js will use for its chart area.
Once the canvas chart paints, this layer fades out and
`pointer-events: none` cedes hover to the interactive chart. -->
<div class="elev-fallback" class:hidden={chartReady} aria-hidden="true">
<div class="y-title">Höhe (m)</div>
<ol class="y-ticks">
{#if elevFallbackYTicks}
{#each elevFallbackYTicks as v (v)}
<li>{v}</li>
{/each}
{/if}
</ol>
<svg
class="elev-fallback-svg"
viewBox="0 0 {FALLBACK_VB_W} {FALLBACK_VB_H}"
preserveAspectRatio="none"
>
<!-- Soft helplines, one per intermediate y-tick. `non-scaling-
stroke` keeps them at 1 px even when the SVG is stretched
horizontally by `preserveAspectRatio="none"`. -->
<g class="elev-fallback-grid">
{#each ELEV_FALLBACK_GRID_Y as gy (gy)}
<line
x1="0"
y1={gy}
x2={FALLBACK_VB_W}
y2={gy}
vector-effect="non-scaling-stroke"
/>
{/each}
</g>
{#if elevFallback.fill}
<path d={elevFallback.fill} class="elev-fallback-fill" />
<path
d={elevFallback.line}
class="elev-fallback-line"
vector-effect="non-scaling-stroke"
/>
{/if}
</svg>
<ol class="x-ticks">
{#each elevFallbackXTicks as v (v)}
<li>{v.toFixed(1)}</li>
{/each}
</ol>
<div class="x-title">Distanz (km)</div>
</div>
<canvas bind:this={canvas} onmouseleave={onCanvasMouseLeave}></canvas>
</div>
<style>
.elevation {
position: relative;
width: 100%;
height: 220px;
margin-top: 1rem;
background: var(--color-surface);
border-radius: var(--radius-card);
padding: 0.75rem 1rem;
box-shadow: var(--shadow-sm);
}
canvas {
width: 100% !important;
height: 100% !important;
position: relative;
z-index: 2;
}
/* SSR fallback grid: y-title (rotated) + y-ticks form the left gutter,
* x-ticks + x-title form the bottom gutter, the SVG plot fills the
* remaining cell. Sized with `calc(100% - 2*padding)` rather than the
* top/right/bottom/left-inset shortcut, because `<svg>` is a replaced
* element with an intrinsic aspect ratio from `viewBox` that some
* browsers let win over a `bottom` constraint — the full-width 220 px
* chart was spilling past its rounded box at the bottom.
*
* The canvas sits on top (z-index 2) so once Chart.js paints, its
* scene fully covers this fallback; we still fade the fallback out so
* any anti-aliased edge gaps don't leak through. */
.elev-fallback {
position: absolute;
top: 0.75rem;
left: 1rem;
width: calc(100% - 2rem);
height: calc(100% - 1.5rem);
z-index: 1;
opacity: 1;
transition: opacity 250ms ease;
pointer-events: none;
display: grid;
grid-template-columns: 0.85rem 2rem 1fr;
grid-template-rows: 1fr 0.9rem 0.9rem;
font-size: 0.7rem;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.elev-fallback.hidden {
opacity: 0;
}
.y-title {
grid-row: 1;
grid-column: 1;
writing-mode: vertical-rl;
transform: rotate(180deg);
text-align: center;
align-self: center;
justify-self: center;
color: var(--color-text-secondary);
letter-spacing: 0.01em;
}
/* Y-ticks reset list defaults so they read as plain labels. `space-
* between` aligns the first/last items with the plot's top/bottom
* edges — same Y-range the SVG path uses (no altitude padding), so
* the topmost label sits on the highest peak and the bottom one at
* the lowest trough. */
.y-ticks {
grid-row: 1;
grid-column: 2;
margin: 0;
padding: 0 0.3rem 0 0;
list-style: none;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
line-height: 1;
}
.y-ticks li::after {
content: ' m';
opacity: 0.55;
}
.elev-fallback-svg {
grid-row: 1;
grid-column: 3;
width: 100%;
height: 100%;
}
/* X-ticks: 0 / mid / max, evenly distributed along the bottom of the
* plot so they line up with the SVG's left edge, midpoint, and right
* edge respectively. */
.x-ticks {
grid-row: 2;
grid-column: 3;
margin: 0;
padding: 0.15rem 0 0;
list-style: none;
display: flex;
justify-content: space-between;
align-items: flex-start;
line-height: 1;
}
.x-title {
grid-row: 3;
grid-column: 3;
text-align: center;
color: var(--color-text-secondary);
letter-spacing: 0.01em;
line-height: 1;
padding-top: 0.05rem;
}
.elev-fallback-grid line {
stroke: currentColor;
stroke-width: 1;
opacity: 0.15;
}
.elev-fallback-fill {
fill: var(--color-primary);
fill-opacity: 0.18;
}
.elev-fallback-line {
fill: none;
stroke: var(--color-primary);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
</style>
+365
View File
@@ -0,0 +1,365 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Route from '@lucide/svelte/icons/route';
import Clock from '@lucide/svelte/icons/clock';
import TrendingUp from '@lucide/svelte/icons/trending-up';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import Mountain from '@lucide/svelte/icons/mountain';
import CalendarRange from '@lucide/svelte/icons/calendar-range';
import { resolveCanton } from '$lib/data/cantons';
import type { HikeManifestEntry } from '$types/hikes';
interface Props {
hike: HikeManifestEntry;
}
const { hike }: Props = $props();
const durationLabel = $derived(
hike.durationMin !== null && hike.durationMin > 0
? `${Math.floor(hike.durationMin / 60)}h ${hike.durationMin % 60}m`
: '—'
);
// SAC trail-sign colour scheme (matches the detail page):
// T1 yellow Wegweiser, T2/T3 white-red-white Bergwanderweg,
// T4T6 white-blue-white Alpinwanderweg.
const sacBand = $derived.by<'yellow' | 'red' | 'blue'>(() => {
if (hike.difficulty === 'T1') return 'yellow';
if (hike.difficulty === 'T2' || hike.difficulty === 'T3') return 'red';
return 'blue';
});
const MONTHS_DE_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
const seasonLabel = $derived.by(() => {
const a = hike.seasonStart;
const b = hike.seasonEnd;
if (a == null || b == null) return null;
if (a < 1 || a > 12 || b < 1 || b > 12) return null;
return `${MONTHS_DE_SHORT[a - 1]}${MONTHS_DE_SHORT[b - 1]}`;
});
// "Neu" badge for hikes published within the last 30 days. Uses the
// frontmatter date (`YYYY-MM-DD`) compared against the build clock —
// good enough for a prerendered listing page that rebuilds on every
// content change.
const isRecent = $derived.by(() => {
const t = Date.parse(hike.date);
if (!Number.isFinite(t)) return false;
const days = (Date.now() - t) / 86_400_000;
return days >= 0 && days <= 30;
});
const canton = $derived(resolveCanton(hike.canton));
</script>
<a class="card" href={resolve('/hikes/[slug]', { slug: hike.slug })} style="view-transition-name: hike-{hike.slug}; view-transition-class: hike-fly-in">
<div class="cover">
{#if hike.cover.src}
<picture>
<source type="image/avif" srcset={hike.cover.srcsetAvif} sizes="(max-width: 600px) 100vw, 400px" />
<source type="image/webp" srcset={hike.cover.srcsetWebp} sizes="(max-width: 600px) 100vw, 400px" />
<img
src={hike.cover.src}
alt={hike.cover.alt}
width={hike.cover.width}
height={hike.cover.height}
loading="lazy"
decoding="async"
/>
</picture>
{:else}
<div class="cover-placeholder"></div>
{/if}
{#if hike.icon}
<span class="icon-pin" aria-hidden="true">
<img src={hike.icon} alt="" />
</span>
{/if}
<span class="sac-pin" aria-label="SAC-Schwierigkeit {hike.difficulty}">
<span class="sac-marker sac-marker-{sacBand}">{hike.difficulty}</span>
</span>
{#if isRecent}
<span class="recent-badge">Neu</span>
{/if}
</div>
<div class="body">
<header class="head">
<h2 class="title">{hike.title}</h2>
{#if hike.region}
<p class="region">
{#if canton}
<img
class="canton-emblem"
src={canton.emblemUrl}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
/>
{/if}<span class="region-text"
>{hike.region}{hike.canton && hike.canton !== hike.region
? `, ${hike.canton}`
: ''}</span
>
</p>
{/if}
</header>
<div class="metrics">
<span title="Distanz"><Route size={14} strokeWidth={1.75} aria-hidden="true" />{hike.distanceKm.toFixed(1)} km</span>
<span title="Dauer"><Clock size={14} strokeWidth={1.75} aria-hidden="true" />{durationLabel}</span>
<span title="Aufstieg"><TrendingUp size={14} strokeWidth={1.75} aria-hidden="true" />{hike.elevationGainM} m</span>
<span title="Abstieg"><TrendingDown size={14} strokeWidth={1.75} aria-hidden="true" />{hike.elevationLossM} m</span>
</div>
{#if (hike.elevationMinM !== null && hike.elevationMaxM !== null) || seasonLabel}
<footer class="foot">
{#if hike.elevationMinM !== null && hike.elevationMaxM !== null}
<span class="chip" title="Höhenlage">
<Mountain size={12} strokeWidth={1.75} aria-hidden="true" />{hike.elevationMinM}{hike.elevationMaxM} m
</span>
{/if}
{#if seasonLabel}
<span class="chip" title="Empfohlene Saison">
<CalendarRange size={12} strokeWidth={1.75} aria-hidden="true" />{seasonLabel}
</span>
{/if}
</footer>
{/if}
</div>
</a>
<style>
.card {
display: flex;
flex-direction: column;
text-decoration: none;
color: inherit;
background: var(--color-surface);
border-radius: var(--radius-card);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: scale var(--transition-normal), box-shadow var(--transition-normal);
height: 100%;
}
.card:hover {
scale: 1.02;
box-shadow: var(--shadow-hover);
}
.cover {
position: relative;
aspect-ratio: 16 / 10;
background: var(--color-bg-elevated);
overflow: hidden;
}
picture,
.cover-placeholder {
display: block;
width: 100%;
height: 100%;
}
picture img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Per-route identity icon, top-left of cover. Floats directly on the
* image — a soft drop-shadow keeps it legible without a backdrop. */
.icon-pin {
position: absolute;
top: 0.55rem;
left: 0.55rem;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
filter: drop-shadow(0 2px 4px rgb(0 0 0 / 0.45));
}
.icon-pin img {
width: 100%;
height: 100%;
object-fit: contain;
}
/* SAC difficulty badge, top-right of cover. Same approach — sits on
* the image with a drop-shadow for separation, no white backdrop. */
.sac-pin {
position: absolute;
top: 0.55rem;
right: 0.55rem;
display: inline-flex;
align-items: center;
filter: drop-shadow(0 2px 4px rgb(0 0 0 / 0.45));
}
.sac-marker {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
}
.sac-marker-yellow {
width: 36px;
color: #1a1a1a;
background: #f5a623;
clip-path: polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%);
/* Text in the rectangular left portion (arrow tip is right 25%). */
justify-content: flex-start;
padding-left: 0.45rem;
}
.sac-marker-red,
.sac-marker-blue {
width: 28px;
color: #fff;
text-shadow: 0 1px 1px rgb(0 0 0 / 0.45);
border-radius: 2px;
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.18);
}
.sac-marker-red {
background: linear-gradient(
to bottom,
#fff 0 25%,
#dc1d2a 25% 75%,
#fff 75% 100%
);
}
.sac-marker-blue {
background: linear-gradient(
to bottom,
#fff 0 25%,
#2965c8 25% 75%,
#fff 75% 100%
);
}
/* Bottom-left freshness marker — only when the hike was published in
* the last 30 days. Small, brand-coloured, all-caps. */
.recent-badge {
position: absolute;
bottom: 0.55rem;
left: 0.55rem;
padding: 0.18rem 0.6rem;
background: var(--color-primary);
color: var(--color-text-on-primary);
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
border-radius: var(--radius-pill);
box-shadow: 0 2px 6px rgb(0 0 0 / 0.25);
}
.body {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.9rem 1rem 1rem;
flex: 1 1 auto;
}
.head {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.title {
margin: 0;
font-size: 1.2rem;
line-height: 1.25;
color: var(--color-text-primary);
}
.region {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.canton-emblem {
flex: 0 0 auto;
width: 18px;
height: 22px;
object-fit: contain;
/* Most cantonal arms are tall-rectangle shields, a couple (Schwyz,
* Solothurn) are squarer — `contain` keeps the proportions correct
* inside the fixed slot so a row of cards stays visually aligned. */
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.18));
}
.region-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metrics {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.85rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.metrics span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.metrics :global(svg) {
color: var(--color-primary);
flex: 0 0 auto;
}
.foot {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: auto;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
border-radius: var(--radius-pill);
}
.chip :global(svg) {
color: var(--color-text-secondary);
flex: 0 0 auto;
}
</style>
+264
View File
@@ -0,0 +1,264 @@
<script lang="ts">
import { getHikeContext } from './hikeContext.svelte';
import { focused } from './focusedImageStore.svelte';
import { addScrollAnchor } from './scrollAnchors';
import { dev } from '$app/environment';
import Lock from '@lucide/svelte/icons/lock';
import Clock from '@lucide/svelte/icons/clock';
interface Props {
/** Position in the hike's full chronological image list (0-indexed,
* stable across viewers because it refers to the unfiltered list).
* Use this for route photos — it carries the map sync + elapsed time. */
idx?: number;
/** Source filename of an image in the hike's `images/` dir, for an
* inline prose photo that isn't a route waypoint. Mutually exclusive
* with `idx`. A path is accepted; only the basename is used. */
src?: string;
/** Alt text override for `src` mode. Falls back to the build-time alt. */
alt?: string;
/** Marks a `src`-mode prose image as private (auth-gated + lock badge).
* Read at BUILD time from the prose by build-hikes — it encodes the image
* into the gated `private/` segment. At runtime the component takes the
* visibility from the manifest, so this prop is declarative only. */
private?: boolean;
/** Optional caption shown under the image — narrative blurb, not a
* machine-derived label. Elapsed time is shown automatically (idx mode). */
caption?: string;
}
const { idx, src, alt, caption }: Props = $props();
const ctx = getHikeContext();
// Prose mode: resolve the named image, hiding private ones from viewers who
// may not see them (the gated endpoint would 401 anyway).
const named = $derived.by(() => {
if (!src) return undefined;
const name = src.split('/').pop() ?? src;
const n = ctx().imagesByName[name];
if (!n) return undefined;
if (n.visibility === 'private' && !ctx().showPrivate) return undefined;
return n;
});
$effect(() => {
if (dev && src && !ctx().imagesByName[src.split('/').pop() ?? src]) {
console.warn(
`[HikeImage] No image named "${src}" in this hike. Put it in the hike's ` +
`images/ folder, reference it in the prose, and re-run build-hikes.`
);
}
});
const ip = $derived(idx === undefined ? undefined : ctx().images[idx]);
const visible = $derived(ip ? ctx().visibleImages.includes(ip) : false);
const visibleIdx = $derived(visible && ip ? ctx().visibleImages.indexOf(ip) : -1);
const isActive = $derived(visibleIdx >= 0 && focused.index === visibleIdx);
// Find the track point closest in time to this image. Used by the
// page-level scroll listener to interpolate a "current trail position"
// between adjacent images as the reader scrolls past them.
const trackIdx = $derived.by(() => {
const t = ip?.timestamp;
const track = ctx().track;
if (typeof t !== 'number' || !track) return -1;
let bestIdx = 0;
let bestDelta = Infinity;
for (let i = 0; i < track.length; i++) {
const tt = track[i][3];
if (typeof tt !== 'number') continue;
const d = Math.abs(tt - t);
if (d < bestDelta) {
bestDelta = d;
bestIdx = i;
}
}
return bestDelta === Infinity ? -1 : bestIdx;
});
// Elapsed time since the hike start (first timestamped track point) — same
// "nach X" the photo strip shows, not the absolute wall-clock time.
const elapsedLabel = $derived.by(() => {
const t = ip?.timestamp;
const track = ctx().track;
if (typeof t !== 'number' || !track) return null;
let start: number | null = null;
for (const p of track) {
if (typeof p[3] === 'number') {
start = p[3];
break;
}
}
if (start === null) return null;
const ms = t - start;
if (!Number.isFinite(ms) || ms < 0) return null;
const totalMin = Math.round(ms / 60000);
if (totalMin < 60) return `${totalMin} min`;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return m === 0 ? `${h} h` : `${h} h ${m} min`;
});
let figure: HTMLElement | undefined = $state();
// Register this image's DOM element as a scroll anchor. The page reads
// these anchors on each scroll frame to compute the active trail
// position. Desktop-only — there's no sticky map to drive on mobile.
$effect(() => {
if (!figure) return;
if (visibleIdx < 0 || trackIdx < 0) return;
if (typeof window === 'undefined') return;
if (!window.matchMedia('(min-width: 1024px)').matches) return;
return addScrollAnchor({ element: figure, trackIdx, visibleIdx });
});
</script>
{#if src}
{#if named}
<figure class="hike-image">
<picture>
<source type="image/avif" srcset={named.srcsetAvif} sizes="(max-width: 680px) 100vw, 680px" />
<source type="image/webp" srcset={named.srcsetWebp} sizes="(max-width: 680px) 100vw, 680px" />
<img
src={named.src}
alt={alt ?? named.alt}
width={named.width}
height={named.height}
loading="lazy"
decoding="async"
/>
</picture>
{#if named.visibility === 'private'}
<span class="private" title="Privates Bild — nur für eingeloggte Benutzer sichtbar">
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
privat
</span>
{/if}
{#if caption}
<figcaption>{caption}</figcaption>
{/if}
</figure>
{/if}
{:else if ip && visible}
<figure class="hike-image" class:active={isActive} bind:this={figure}>
<img
src={ip.src}
alt={ip.alt}
loading="lazy"
decoding="async"
/>
{#if ip.visibility === 'private'}
<span class="private" title="Privates Bild — nur für eingeloggte Benutzer sichtbar">
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
privat
</span>
{/if}
{#if elapsedLabel}
<span class="shot-time" title="Zeit seit Start">
<Clock size={11} strokeWidth={2.25} aria-hidden="true" />
nach {elapsedLabel}
</span>
{/if}
{#if caption}
<figcaption>{caption}</figcaption>
{/if}
</figure>
{/if}
<style>
.hike-image {
position: relative;
/* Cap the width so that in the single-column (mobile/tablet) layout the
* photo doesn't blow up to the full content width on wider screens.
* On the desktop two-column layout the prose column is already narrower
* than this, so it stays full-bleed-in-column there. Centered via
* auto inline margins. */
max-width: 680px;
margin: 2rem auto;
border-radius: var(--radius-card);
overflow: hidden;
background: #14181f;
box-shadow: var(--shadow-md);
transition: box-shadow 280ms ease;
}
.hike-image.active {
box-shadow:
0 18px 32px -8px color-mix(in oklab, var(--color-primary) 45%, transparent),
0 6px 14px -6px rgb(0 0 0 / 0.25);
}
.hike-image picture {
display: block;
}
.hike-image img {
display: block;
width: 100%;
height: auto;
object-fit: cover;
background: #14181f;
}
figcaption {
padding: 0.6rem 0.85rem 0.75rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
background: var(--color-surface);
font-style: italic;
line-height: 1.45;
}
.private {
position: absolute;
top: 0.6rem;
left: 0.6rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.18rem 0.5rem;
border-radius: var(--radius-pill);
background: rgb(0 0 0 / 0.55);
color: #fff;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Capture time, bottom-right so it never collides with the private badge. */
.shot-time {
position: absolute;
bottom: 0.6rem;
right: 0.6rem;
display: inline-flex;
align-items: center;
gap: 0.28rem;
font-size: 0.7rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
padding: 0.2rem 0.55rem;
border-radius: var(--radius-pill);
background: rgb(0 0 0 / 0.55);
color: #fff;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Sits within the rounded image; if a caption follows, the figure grows so
* the badge stays over the photo (absolute to the figure, image is the top
* block). */
.hike-image:has(figcaption) .shot-time {
bottom: auto;
top: 0.6rem;
right: 0.6rem;
}
@media (prefers-reduced-motion: reduce) {
.hike-image {
transition: none;
}
}
</style>
+969
View File
@@ -0,0 +1,969 @@
<script lang="ts">
import type { Attachment } from 'svelte/attachments';
import type { HikeTrackPoint, ImagePoint, HikeStage } from '$types/hikes';
import { hover, setHover, clearHover } from './hoverStore.svelte';
import { stage } from './stageStore.svelte';
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
import { focused, setFocused, clearFocused } from './focusedImageStore.svelte';
import Map from '@lucide/svelte/icons/map';
import Satellite from '@lucide/svelte/icons/satellite';
import Landmark from '@lucide/svelte/icons/landmark';
import Layers from '@lucide/svelte/icons/layers';
import Camera from '@lucide/svelte/icons/camera';
import CameraOff from '@lucide/svelte/icons/camera-off';
import Locate from '@lucide/svelte/icons/locate';
import LocateOff from '@lucide/svelte/icons/locate-off';
import Maximize2 from '@lucide/svelte/icons/maximize-2';
interface Props {
track: HikeTrackPoint[];
imagePoints?: ImagePoint[];
/** When false, private images are hidden — anonymous viewers only see
* public ones. Logged-in users get the full set. */
showPrivate?: boolean;
/** Initial map centre `[lat, lng]`. When provided alongside
* `initialZoom`, the map opens with `setView(center, zoom)` instead
* of `fitBounds(track)` — used by the detail page to align Leaflet's
* first paint with the SSR-rendered static hero map. */
initialCenter?: [number, number];
initialZoom?: number;
/** Fires once the schematic tile layer's first batch of tiles has
* finished loading — i.e. the map is visually complete. The detail
* page uses this to fade out the SSR-rendered static hero. */
onReady?: () => void;
/** Polyline colour. Defaults to Nord red. Callers set this to the
* SAC-tier colour so the live trail matches the colour of the same
* route on the /hikes overview map (orange for T1, red for T2/T3,
* blue for T4T6). */
trackColor?: string;
/** Stage ranges for a multi-day hike. When a stage is active (shared
* stageStore) the map highlights it, dims the rest, zooms to it, and
* scopes photo markers to that stage. */
stages?: HikeStage[] | null;
/** Whether the hike lies in a swisstopo-covered region (CH/LI). Drives
* the schematic max zoom (19 in-region vs 17 for OpenTopoMap abroad)
* and whether the CH/LI-only Dufour layer is offered. */
swissRegion?: boolean;
}
const {
track,
imagePoints = [],
showPrivate = false,
initialCenter,
initialZoom,
onReady,
trackColor,
stages = null,
swissRegion = true
}: Props = $props();
// User-location toggle moved inside the map UI. localStorage-persisted so
// returning visitors get the same state. Permission errors surface as a
// small inline message just under the controls.
const GPS_STORAGE_KEY = 'hikes:gpsEnabled';
let enableUserLocation = $state(false);
let locationError = $state<string | null>(null);
$effect(() => {
if (typeof window === 'undefined') return;
if (window.localStorage.getItem(GPS_STORAGE_KEY) === '1') enableUserLocation = true;
});
$effect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(GPS_STORAGE_KEY, enableUserLocation ? '1' : '0');
});
// Close the layer menu when clicking anywhere outside of it. The opening
// click on the button calls stopPropagation, so this handler never sees
// the click that flipped layerMenuOpen to true.
$effect(() => {
if (!layerMenuOpen) return;
function onAway(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target && !target.closest('.layer-menu')) {
layerMenuOpen = false;
}
}
window.addEventListener('click', onAway);
return () => window.removeEventListener('click', onAway);
});
function toggleLocation() {
if (enableUserLocation) {
enableUserLocation = false;
locationError = null;
return;
}
if (typeof window === 'undefined') return;
const hasTauri = '__TAURI_INTERNALS__' in window;
const hasWebGeo = 'geolocation' in navigator;
if (!hasTauri && !hasWebGeo) {
locationError = 'Geolocation steht in diesem Browser nicht zur Verfügung.';
return;
}
locationError = null;
enableUserLocation = true;
}
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
type LayerDef = { label: string; icon: typeof Map; maxZoom: number };
// Schematic max zoom is region-aware: swisstopo reaches z19 over CH/LI,
// but the global fallback (OpenTopoMap) only serves to z17.
const LAYER_DEFS: Record<BaseLayer, LayerDef> = $derived({
schematic: { label: 'Karte', icon: Map, maxZoom: swissRegion ? 19 : 17 },
aerial: { label: 'Luftbild', icon: Satellite, maxZoom: 19 },
// Dufour Map (18451864): swisstopo's historical layer, only goes up
// to roughly z16. We cap the map's maxZoom when this layer is active.
dufour: { label: 'Dufour (1864)', icon: Landmark, maxZoom: 16 }
});
// The Dufour historical layer exists only for CH/LI — hide it abroad.
const layerOptions = $derived(
Object.entries(LAYER_DEFS).filter(([key]) => swissRegion || key !== 'dufour') as [
BaseLayer,
LayerDef
][]
);
let showPhotos = $state(true);
let baseLayer = $state<BaseLayer>('schematic');
let layerMenuOpen = $state(false);
// Re-fit-bounds callback — populated once the map and polyline are
// alive inside the Leaflet attachment. Null until then so the button
// can be hidden / disabled.
let recenterMap = $state<(() => void) | null>(null);
// Cleanup hover/focus state when the component unmounts.
$effect(() => {
return () => {
clearHover();
clearFocused();
};
});
// The strip and the map share `focused` via the focusedImageStore. The map
// owns the visible filtered list internally; the strip works against the
// same filtered list, so the index is consistent between them.
const mapAttachment: Attachment<HTMLElement> = (node) => {
let cleanup: (() => void) | undefined;
let cancelled = false;
(async () => {
const L = await import('leaflet');
if (cancelled || !node.isConnected) return;
const latLngs: [number, number][] = track.map((p) => [p[1], p[0]]);
const map = L.map(node, {
// On-map attribution control removed for a cleaner frame; the
// required swisstopo credit is repeated in the page's meta footer
// ("Kartendaten © swisstopo").
attributionControl: false,
zoomControl: true,
preferCanvas: true
});
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
schematic: L.tileLayer(TILE_URL.karte, {
maxZoom: LAYER_DEFS.schematic.maxZoom,
minZoom: 7,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
}),
aerial: L.tileLayer(TILE_URL.luftbild, {
maxZoom: LAYER_DEFS.aerial.maxZoom,
minZoom: 7,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
}),
dufour: L.tileLayer(TILE_URL.dufour, {
maxZoom: LAYER_DEFS.dufour.maxZoom,
minZoom: 7,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
})
};
tileLayers.schematic.addTo(map);
let currentBase: BaseLayer = 'schematic';
// First-paint handover: when the schematic tile layer finishes
// loading its initial batch, fire `onReady` so the static hero
// can fade out. The map already opened at the static pose via
// `setView(initialCenter, initialZoom)` below, so the live
// tiles paint over the static at the same framing — no second
// animation is needed (and a `flyToBounds` here would actually
// cause a visible wobble on hikes whose bbox sits right at an
// integer-zoom boundary, where the static's fit and Leaflet's
// runtime fit disagree by one zoom step at the user's actual
// container size).
tileLayers.schematic.once('load', () => {
onReady?.();
});
// Canvas-rendered polylines can't resolve CSS custom properties,
// so the caller hands us a literal colour. Falls back to Nord red
// for any caller that hasn't been updated yet.
const trailColor =
trackColor ??
(getComputedStyle(document.documentElement).getPropertyValue('--red').trim() ||
'#bf616a');
// Non-interactive: hover is driven by the whole-map `mousemove`
// handler below (snap-to-nearest), so the line itself needn't grab
// the pointer cursor or events.
const polyline = L.polyline(latLngs, {
color: trailColor,
weight: 4,
opacity: 0.95,
interactive: false
}).addTo(map);
// Brighter overlay drawn over the active stage (multi-day hikes); the
// base line is dimmed underneath it. Empty until a stage is selected.
const stageOverlay = L.polyline([] as [number, number][], {
color: trailColor,
weight: 6,
opacity: 1,
interactive: false
});
L.circleMarker(latLngs[0], {
radius: 6,
fillColor: '#a3be8c',
fillOpacity: 1,
color: '#fff',
weight: 2
}).addTo(map);
L.circleMarker(latLngs[latLngs.length - 1], {
radius: 6,
fillColor: '#bf616a',
fillOpacity: 1,
color: '#fff',
weight: 2
}).addTo(map);
const initialBounds = polyline.getBounds();
// When the caller supplies a specific center+zoom (e.g. the detail
// page handing over from a pre-rendered static hero), open with
// `setView` so Leaflet lands on the exact same pose the static
// image was rendered at. Otherwise fall back to fitBounds.
if (initialCenter && typeof initialZoom === 'number') {
map.setView(initialCenter, initialZoom, { animate: false });
} else {
map.fitBounds(initialBounds, { padding: [24, 24] });
}
// Expose a re-focus callback that re-fits the polyline bounds —
// the same view the user started with after dragging or zooming
// somewhere else. Smooth flyToBounds rather than instant fit so
// the transition reads as a deliberate gesture.
recenterMap = () => {
map.flyToBounds(initialBounds, {
padding: [24, 24],
duration: 0.6,
easeLinearity: 0.25
});
};
// Hovered-vertex marker (driven by the shared hover store). Rendered
// as a lucide MapPin in a divIcon so its tip aligns with the actual
// track point — circle markers were ambiguous about which lat/lng
// they were claiming.
const hoverIcon = L.divIcon({
className: 'hike-hover-pin',
html:
'<svg viewBox="0 0 24 24" aria-hidden="true">' +
'<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" fill="currentColor" stroke="rgba(0,0,0,0.35)" stroke-width="1.25" stroke-linejoin="round"/>' +
'<circle cx="12" cy="10" r="3" fill="#fff"/>' +
'</svg>',
iconSize: [28, 34],
iconAnchor: [14, 32]
});
const hoverMarker = L.marker(latLngs[0], {
icon: hoverIcon,
interactive: false,
keyboard: false,
zIndexOffset: 1000
});
// Image markers — compact camera badges. The full image lives in the
// HikePhotoStage below the strip, so the map keeps the trail visible.
// Hovering or clicking a marker just writes to the focus store; the
// stage and strip react.
let photoLayer = L.layerGroup().addTo(map);
// Parallel arrays: the visible subset of imagePoints (post visibility
// filter) and the Leaflet markers we built for them. Their indices
// match what the photo strip is using, so a strip-side `focused.index`
// maps directly into `visibleMarkers`.
let visiblePoints: ImagePoint[] = [];
let visibleMarkers: ReturnType<typeof L.marker>[] = [];
// Lucide Camera path, inlined so the divIcon stays self-contained.
const cameraSvg =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/>' +
'<circle cx="12" cy="13" r="3"/>' +
'</svg>';
// Nearest track sample (by time) to a photo — used to test which
// stage a photo belongs to when scoping markers to the active stage.
function nearestTrackIdx(ts: number): number {
let best = -1;
let bestD = Infinity;
for (let i = 0; i < track.length; i++) {
const t = track[i][3];
if (typeof t !== 'number') continue;
const d = Math.abs(t - ts);
if (d < bestD) {
bestD = d;
best = i;
}
}
return best;
}
function photoInActiveStage(ip: ImagePoint): boolean {
const active = stage.active;
if (active === null || !stages || !stages[active]) return true;
if (typeof ip.timestamp !== 'number') return false;
const s = stages[active];
const idx = nearestTrackIdx(ip.timestamp);
return idx >= s.startIdx && idx <= s.endIdx;
}
function renderPhotos() {
photoLayer.clearLayers();
visiblePoints = [];
visibleMarkers = [];
if (!showPhotos) return;
for (const ip of imagePoints) {
if (ip.visibility === 'private' && !showPrivate) continue;
const visibleIdx = visiblePoints.length;
visiblePoints.push(ip);
// Keep `visiblePoints` aligned with the strip's index space, but
// only draw a marker when the photo is in the active stage.
if (!photoInActiveStage(ip)) continue;
const altSafe = ip.alt.replace(/"/g, '&quot;');
const isPrivate = ip.visibility === 'private';
const icon = L.divIcon({
className: `hike-photo-marker${isPrivate ? ' is-private' : ''}`,
html: `<span class="badge" title="${altSafe}">${cameraSvg}</span>`,
iconSize: [28, 28],
iconAnchor: [14, 14]
});
const marker = L.marker([ip.lat, ip.lng], { icon });
marker.on('mouseover', () => {
// Hover preview → stage. Distinct source so the strip
// skips scroll (jerky across dense clusters) and the
// map skips its own flyTo + focus ring.
setFocused(visibleIdx, 'map-hover');
// Also drive the chart cursor via the nearest track sample.
if (typeof ip.timestamp !== 'number') return;
let bestIdx = 0;
let bestDelta = Infinity;
for (let i = 0; i < track.length; i++) {
const t = track[i][3];
if (typeof t !== 'number') continue;
const d = Math.abs(t - ip.timestamp);
if (d < bestDelta) {
bestDelta = d;
bestIdx = i;
}
}
setHover(bestIdx, 'image');
});
// Clear chart hover but keep `focused` sticky so the stage
// keeps showing the last hovered image.
marker.on('mouseout', () => clearHover());
// Click (touch fallback): same semantics as a strip click —
// scroll the strip card into view and centre the map.
marker.on('click', () => setFocused(visibleIdx, 'map'));
marker.addTo(photoLayer);
visibleMarkers.push(marker);
}
}
renderPhotos();
// Focus ring: a non-vector divIcon (so we can CSS-animate it under
// Leaflet's canvas renderer). Created lazily on first focus.
const focusIcon = L.divIcon({
className: 'hike-photo-focus-ring',
html: '<div class="ring"></div><div class="ring delay"></div>',
iconSize: [80, 80],
iconAnchor: [40, 40]
});
let focusMarker: ReturnType<typeof L.marker> | null = null;
// React to the shared hover store: drive the polyline cursor marker.
// When the hover comes from the chart and the point is outside the
// currently-visible map area, pan the map so the pin stays in view —
// using `pad(-0.12)` shrinks the trigger bounds so we pan a touch
// before the pin actually hits the edge.
const stopHoverEffect = $effect.root(() => {
$effect(() => {
if (hover.index === null || hover.index < 0 || hover.index >= latLngs.length) {
hoverMarker.remove();
return;
}
const ll = latLngs[hover.index];
hoverMarker.setLatLng(ll);
hoverMarker.addTo(map);
// Only auto-pan for cursors driven from elsewhere (chart /
// scroll tracker). A map-sourced hover means the user is
// already pointing here, so panning would fight them.
if (hover.source === 'chart' || hover.source === 'scroll') {
const inner = map.getBounds().pad(-0.12);
if (!inner.contains(ll)) {
map.panTo(ll, { animate: true, duration: 0.35, easeLinearity: 0.3 });
}
}
});
});
// React to the shared focus store: when the strip selects a photo we
// fly the map there and drop a pulsing focus ring on top of the marker.
// Map-side writes (hover or click) are ignored — the user is already
// looking at that marker, no need to pan or ring it.
const stopFocusEffect = $effect.root(() => {
$effect(() => {
const idx = focused.index;
if (focused.source === 'map' || focused.source === 'map-hover') return;
if (idx === null || idx < 0 || idx >= visiblePoints.length) {
if (focusMarker) {
focusMarker.remove();
focusMarker = null;
}
return;
}
const ip = visiblePoints[idx];
// For inline-scroll focus changes we don't want to fly the map
// on every image boundary — the continuous scroll-pin already
// shows the reader where they are. Only fly if the focused
// marker is currently off the visible viewport. Other sources
// (strip click, chevron, keyboard) keep the full flyTo so the
// gesture feels deliberate.
const target: [number, number] = [ip.lat, ip.lng];
const shouldFly =
focused.source !== 'inline' || !map.getBounds().pad(-0.05).contains(target);
if (shouldFly) {
map.flyTo(target, Math.max(map.getZoom(), 15), {
duration: 0.7,
easeLinearity: 0.25
});
}
if (!focusMarker) {
focusMarker = L.marker([ip.lat, ip.lng], {
icon: focusIcon,
interactive: false,
keyboard: false,
zIndexOffset: -100
}).addTo(map);
} else {
focusMarker.setLatLng([ip.lat, ip.lng]);
if (!map.hasLayer(focusMarker)) focusMarker.addTo(map);
}
});
});
// Elevation tracking: rather than requiring the pointer to be exactly
// on the thin trail, snap the chart cursor to the nearest track point
// whenever the mouse is anywhere within HOVER_SNAP_PX of the route.
// The track is cached in layer-point (pixel) space so each pointer
// move is just cheap distance maths; the cache is rebuilt on zoom/
// move (layer points are pan-invariant, but rebuilding on moveend
// keeps it correct regardless of how the view changed).
const HOVER_SNAP_PX = 70;
let projected: { x: number; y: number }[] = [];
function reproject() {
projected = latLngs.map((ll) => map.latLngToLayerPoint(ll));
}
reproject();
map.on('zoomend moveend', reproject);
map.on('mousemove', (e: { layerPoint: { x: number; y: number } }) => {
if (projected.length === 0) return;
const { x, y } = e.layerPoint;
let bestIdx = 0;
let bestSq = Infinity;
for (let i = 0; i < projected.length; i++) {
const dx = projected[i].x - x;
const dy = projected[i].y - y;
const sq = dx * dx + dy * dy;
if (sq < bestSq) {
bestSq = sq;
bestIdx = i;
}
}
if (bestSq <= HOVER_SNAP_PX * HOVER_SNAP_PX) {
setHover(bestIdx, 'map');
} else if (hover.source === 'map') {
clearHover();
}
});
map.on('mouseout', () => {
if (hover.source === 'map') clearHover();
});
// User location (opt-in).
let userMarker: ReturnType<typeof L.circleMarker> | null = null;
let userAccuracyCircle: ReturnType<typeof L.circle> | null = null;
let userCleanup: (() => void) | undefined;
async function attachUserLocation() {
if (!enableUserLocation) return;
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
const handlePos = (lat: number, lng: number, accuracy: number) => {
if (!userMarker) {
userMarker = L.circleMarker([lat, lng], {
radius: 7,
fillColor: '#5e81ac',
fillOpacity: 1,
color: '#fff',
weight: 2
}).addTo(map);
userAccuracyCircle = L.circle([lat, lng], {
radius: accuracy,
color: '#5e81ac',
fillColor: '#5e81ac',
fillOpacity: 0.1,
weight: 1
}).addTo(map);
} else {
userMarker.setLatLng([lat, lng]);
userAccuracyCircle?.setLatLng([lat, lng]);
userAccuracyCircle?.setRadius(accuracy);
}
};
if (isTauri) {
try {
const geo = await import('@tauri-apps/plugin-geolocation');
const watchId = await geo.watchPosition(
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10_000 },
(pos) => {
if (pos?.coords) handlePos(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy ?? 30);
}
);
userCleanup = () => geo.clearWatch(watchId).catch(() => {});
} catch {
/* Tauri plugin unavailable — fall through to web API */
}
}
if (!userCleanup && 'geolocation' in navigator) {
const id = navigator.geolocation.watchPosition(
(pos) => handlePos(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy),
() => {},
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10_000 }
);
userCleanup = () => navigator.geolocation.clearWatch(id);
}
}
attachUserLocation();
// React to user-toggle of photo markers, base-layer choice, and the
// enableUserLocation prop.
let stageInitialized = false;
const stopReactRoot = $effect.root(() => {
$effect(() => {
renderPhotos();
});
// Active-stage highlight + zoom. The first run (active === null on
// mount) only normalises the base style — it must NOT fly, or it
// would clobber the static-hero → live handover above.
$effect(() => {
const active = stage.active;
if (active !== null && stages && stages[active]) {
const s = stages[active];
stageOverlay.setLatLngs(latLngs.slice(s.startIdx, s.endIdx + 1));
if (!map.hasLayer(stageOverlay)) stageOverlay.addTo(map);
polyline.setStyle({ opacity: 0.28 });
const b = stageOverlay.getBounds();
if (b.isValid()) {
map.flyToBounds(b, { padding: [40, 40], duration: 0.6, easeLinearity: 0.25 });
}
stageInitialized = true;
} else {
if (map.hasLayer(stageOverlay)) stageOverlay.remove();
polyline.setStyle({ opacity: 0.95 });
if (stageInitialized) {
map.flyToBounds(initialBounds, { padding: [24, 24], duration: 0.6, easeLinearity: 0.25 });
}
}
});
$effect(() => {
if (baseLayer === currentBase) return;
tileLayers[currentBase].remove();
tileLayers[baseLayer].addTo(map);
// Each historical layer caps out at a lower zoom — clamp the
// map so we don't end up on a blank tile, and force the
// current zoom back down if it's beyond the new ceiling.
const newMax = LAYER_DEFS[baseLayer].maxZoom;
map.setMaxZoom(newMax);
if (map.getZoom() > newMax) map.setZoom(newMax);
currentBase = baseLayer;
});
$effect(() => {
if (!enableUserLocation && userCleanup) {
userCleanup();
userCleanup = undefined;
if (userMarker) userMarker.remove();
if (userAccuracyCircle) userAccuracyCircle.remove();
userMarker = null;
userAccuracyCircle = null;
} else if (enableUserLocation && !userCleanup) {
attachUserLocation();
}
});
});
cleanup = () => {
userCleanup?.();
stopHoverEffect();
stopFocusEffect();
stopReactRoot();
if (focusMarker) focusMarker.remove();
recenterMap = null;
map.remove();
};
})();
return () => {
cancelled = true;
cleanup?.();
};
};
</script>
<div class="map-wrap">
<div class="map" {@attach mapAttachment}></div>
<div class="map-controls">
<div class="layer-menu" class:open={layerMenuOpen}>
<button
type="button"
class="round-btn"
aria-label="Kartenebene wählen"
aria-haspopup="menu"
aria-expanded={layerMenuOpen}
onclick={(e) => {
e.stopPropagation();
layerMenuOpen = !layerMenuOpen;
}}
>
<Layers size={20} strokeWidth={2} aria-hidden="true" />
</button>
{#if layerMenuOpen}
<div class="layer-popover" role="menu">
{#each layerOptions as [key, def] (key)}
{@const Icon = def.icon}
<button
type="button"
role="menuitemradio"
aria-checked={baseLayer === key}
class:active={baseLayer === key}
onclick={() => {
baseLayer = key as BaseLayer;
layerMenuOpen = false;
}}
>
<Icon size={14} strokeWidth={1.75} aria-hidden="true" />
{def.label}
</button>
{/each}
</div>
{/if}
</div>
{#if recenterMap}
<button
type="button"
class="round-btn"
aria-label="Auf die Tour zurückzentrieren"
title="Karte auf die gesamte Tour zurückzentrieren"
onclick={() => recenterMap?.()}
>
<Maximize2 size={18} strokeWidth={2} aria-hidden="true" />
</button>
{/if}
{#if imagePoints.length > 0}
<button
type="button"
class="round-btn"
class:active={showPhotos}
aria-pressed={showPhotos}
aria-label={showPhotos ? 'Fotos auf der Karte ausblenden' : 'Fotos auf der Karte anzeigen'}
title={showPhotos ? 'Fotos auf der Karte ausblenden' : 'Fotos auf der Karte anzeigen'}
onclick={() => (showPhotos = !showPhotos)}
>
{#if showPhotos}
<Camera size={20} strokeWidth={2} aria-hidden="true" />
{:else}
<CameraOff size={20} strokeWidth={2} aria-hidden="true" />
{/if}
</button>
{/if}
<button
type="button"
class="round-btn"
class:active={enableUserLocation}
aria-pressed={enableUserLocation}
title={enableUserLocation
? 'Eigenen Standort verbergen'
: 'Eigenen Standort anzeigen — wird lokal berechnet, nicht an Dritte gesendet'}
aria-label={enableUserLocation ? 'Eigenen Standort verbergen' : 'Eigenen Standort anzeigen'}
onclick={toggleLocation}
>
{#if enableUserLocation}
<Locate size={20} strokeWidth={2} aria-hidden="true" />
{:else}
<LocateOff size={20} strokeWidth={2} aria-hidden="true" />
{/if}
</button>
</div>
{#if locationError}
<p class="gps-error" role="status">{locationError}</p>
{/if}
</div>
<style>
.map-wrap {
position: relative;
width: 100%;
}
.map {
width: 100%;
height: 520px;
border-radius: var(--radius-card);
overflow: hidden;
box-shadow: var(--shadow-md);
background: var(--color-bg-elevated);
}
@media (max-width: 560px) {
.map {
height: 360px;
border-radius: var(--radius-lg);
}
}
/* Vertical stack of round controls at the bottom-right of the map.
* Layer (top) → Camera → GPS (bottom). All three share `.round-btn`
* styling; the layer button also anchors a popover menu to its left. */
.map-controls {
position: absolute;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
z-index: 500;
}
.round-btn {
display: grid;
place-items: center;
width: 44px;
height: 44px;
background: var(--color-surface);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: 50%;
box-shadow: var(--shadow-md);
cursor: pointer;
transition:
color var(--transition-fast),
background var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.round-btn:hover {
color: var(--color-primary);
transform: scale(1.05);
box-shadow: var(--shadow-hover);
}
.round-btn.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
.round-btn.active:hover {
color: var(--color-text-on-primary);
}
.layer-menu {
position: relative;
}
.layer-popover {
position: absolute;
right: calc(100% + 0.5rem);
bottom: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.3rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 9.5rem;
white-space: nowrap;
}
.layer-popover button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.7rem;
border: 0;
background: transparent;
color: var(--color-text-primary);
font: inherit;
font-size: 0.85rem;
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
transition: background var(--transition-fast), color var(--transition-fast);
}
.layer-popover button :global(svg) {
color: var(--color-text-tertiary);
flex: 0 0 auto;
}
.layer-popover button:hover {
background: var(--color-bg-elevated);
}
.layer-popover button.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.layer-popover button.active :global(svg) {
color: var(--color-text-on-primary);
}
/* GPS-permission error toast — sits above the bottom-right stack of
* round controls. Up to four 44 px buttons + three 0.5 rem gaps make the
* stack ~216 px tall (incl. the 1 rem bottom inset), so anchor the
* toast at 14 rem to clear it with breathing room. */
.gps-error {
position: absolute;
bottom: 14rem;
right: 1rem;
max-width: 18rem;
margin: 0;
padding: 0.5rem 0.75rem;
background: var(--color-surface);
color: var(--red);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 0.78rem;
z-index: 500;
}
:global(.hike-hover-pin) {
background: transparent !important;
border: 0 !important;
/* Nord red — deliberately off the primary palette so the cursor pin
* reads as a distinct "you are here" marker against the blue-ish
* trail / UI accents. `currentColor` drives the SVG fill. */
color: var(--red);
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.25));
pointer-events: none;
}
:global(.hike-hover-pin svg) {
display: block;
width: 28px;
height: 34px;
}
:global(.hike-photo-marker) {
background: transparent !important;
border: 0 !important;
}
:global(.hike-photo-marker .badge) {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-primary);
color: var(--color-text-on-primary);
border: 2px solid var(--color-surface);
box-shadow: var(--shadow-sm);
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
:global(.hike-photo-marker .badge svg) {
width: 14px;
height: 14px;
}
:global(.hike-photo-marker:hover .badge) {
transform: scale(1.15);
box-shadow: var(--shadow-md);
}
:global(.hike-photo-marker.is-private .badge) {
background: var(--color-bg-elevated);
color: var(--color-primary);
border-color: var(--color-primary);
}
/* Focus ring placed by the photo strip → map sync. Two concentric pulses
* with staggered animation make the ring feel alive without strobing. */
:global(.hike-photo-focus-ring) {
background: transparent !important;
border: 0 !important;
pointer-events: none;
}
:global(.hike-photo-focus-ring .ring) {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid var(--color-primary);
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
animation: hike-photo-focus-pulse 1.6s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
:global(.hike-photo-focus-ring .ring.delay) {
animation-delay: 0.8s;
}
@keyframes hike-photo-focus-pulse {
0% {
transform: scale(0.45);
opacity: 0.95;
}
70% {
opacity: 0.15;
}
100% {
transform: scale(1.25);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
:global(.hike-photo-focus-ring .ring) {
animation: none;
transform: scale(0.9);
opacity: 0.55;
}
}
</style>
@@ -0,0 +1,116 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children?: Snippet;
}
const { children }: Props = $props();
</script>
<article class="hike-prose">
{@render children?.()}
</article>
<style>
.hike-prose {
max-width: 70ch;
margin-inline: auto;
color: var(--color-text-primary);
line-height: 1.7;
font-size: var(--text-base, 1rem);
}
.hike-prose :global(h2) {
margin-top: 2.5rem;
margin-bottom: 1rem;
font-size: var(--text-2xl, 1.5rem);
color: var(--color-text-primary);
}
.hike-prose :global(h3) {
margin-top: 2rem;
margin-bottom: 0.75rem;
font-size: var(--text-xl, 1.25rem);
color: var(--color-text-primary);
}
.hike-prose :global(p) {
margin-block: 1rem;
}
.hike-prose :global(a) {
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 0.15em;
}
.hike-prose :global(a:hover) {
color: var(--color-primary-hover);
}
.hike-prose :global(blockquote) {
margin-block: 1.5rem;
padding: 0.75rem 1rem;
border-inline-start: 3px solid var(--color-primary);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
}
.hike-prose :global(img) {
max-width: 100%;
height: auto;
display: block;
margin-block: 1.5rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
.hike-prose :global(figure) {
margin-block: 1.5rem;
}
.hike-prose :global(figcaption) {
text-align: center;
font-size: var(--text-sm, 0.875rem);
color: var(--color-text-tertiary);
margin-top: 0.5rem;
}
.hike-prose :global(ul),
.hike-prose :global(ol) {
padding-inline-start: 1.5rem;
margin-block: 1rem;
}
.hike-prose :global(li) {
margin-block: 0.4rem;
}
.hike-prose :global(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
background: var(--color-bg-tertiary);
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
}
.hike-prose :global(pre) {
background: var(--color-bg-tertiary);
padding: 1rem;
border-radius: var(--radius-md);
overflow-x: auto;
}
.hike-prose :global(pre code) {
background: transparent;
padding: 0;
}
.hike-prose :global(hr) {
margin-block: 2rem;
border: 0;
border-top: 1px solid var(--color-border);
}
</style>
@@ -0,0 +1,764 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import type { HikeTrackPoint, ImagePoint, HikeStage } from '$types/hikes';
import { focused, setFocused } from './focusedImageStore.svelte';
import { stage } from './stageStore.svelte';
import MapPin from '@lucide/svelte/icons/map-pin';
import Lock from '@lucide/svelte/icons/lock';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Expand from '@lucide/svelte/icons/expand';
import X from '@lucide/svelte/icons/x';
interface Props {
images: ImagePoint[];
track: HikeTrackPoint[];
/** Stage ranges (multi-day hikes). When a stage is active, the strip
* shows only that stage's photos. Indices stay aligned with the full
* list so the shared focus store keeps matching the map. */
stages?: HikeStage[] | null;
}
const { images, track, stages = null }: Props = $props();
// Nearest track index (by time) per image — for testing stage membership.
const imageTrackIdx = $derived(
images.map((ip) => {
if (typeof ip.timestamp !== 'number') return -1;
let best = -1;
let bestD = Infinity;
for (let i = 0; i < track.length; i++) {
const t = track[i][3];
if (typeof t !== 'number') continue;
const d = Math.abs(t - ip.timestamp);
if (d < bestD) {
bestD = d;
best = i;
}
}
return best;
})
);
const activeStageRange = $derived.by(() => {
if (stage.active === null || !stages || !stages[stage.active]) return null;
const s = stages[stage.active];
return { startIdx: s.startIdx, endIdx: s.endIdx };
});
function inActiveStage(i: number): boolean {
const r = activeStageRange;
if (!r) return true;
const idx = imageTrackIdx[i];
return idx >= r.startIdx && idx <= r.endIdx;
}
const startTimestamp = $derived.by(() => {
for (const p of track) {
if (typeof p[3] === 'number') return p[3];
}
return null;
});
function formatElapsed(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return '';
const totalMin = Math.round(ms / 60000);
if (totalMin < 60) return `${totalMin} min`;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return m === 0 ? `${h} h` : `${h} h ${m} min`;
}
const cardEls: Array<HTMLElement | null> = $state([]);
let scrollEl = $state<HTMLDivElement | undefined>(undefined);
// Fullscreen lightbox. Independent of `focused` (which drives the map),
// but opening / navigating also syncs `focused` so the map + strip follow
// whatever is being viewed full-screen.
let lightboxIndex = $state<number | null>(null);
const lightboxOpen = $derived(lightboxIndex !== null);
let closeBtn = $state<HTMLButtonElement | undefined>(undefined);
function openLightbox(i: number): void {
lightboxIndex = i;
setFocused(i, 'strip');
}
function closeLightbox(): void {
lightboxIndex = null;
}
function lightboxStep(dir: -1 | 1): void {
if (lightboxIndex === null) return;
const n = lightboxIndex + dir;
if (n < 0 || n >= images.length) return;
lightboxIndex = n;
setFocused(n, 'strip');
}
// While open: Esc closes, arrows navigate, body scroll is locked, and focus
// moves into the dialog. Keyed on `lightboxOpen` (not the index) so stepping
// between images doesn't re-run the setup or steal focus back to close.
$effect(() => {
if (!lightboxOpen) return;
closeBtn?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeLightbox();
else if (e.key === 'ArrowLeft') {
e.preventDefault();
lightboxStep(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
lightboxStep(1);
}
};
window.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
});
// Recenter the active card horizontally inside the strip on focus change.
// We scroll only the strip's own X axis — `scrollIntoView` would also
// pull the page Y to bring the strip into the viewport, which is not
// what we want here. Map-hover writes are skipped because they fire
// rapidly across dense clusters and would jerk the strip around; map
// clicks and strip/keyboard navigation still recenter.
$effect(() => {
const idx = focused.index;
if (idx === null || idx < 0) return;
if (focused.source === 'map-hover') return;
const el = cardEls[idx];
if (!el || !scrollEl) return;
const targetLeft = el.offsetLeft + el.offsetWidth / 2 - scrollEl.clientWidth / 2;
scrollEl.scrollTo({ left: targetLeft, behavior: 'smooth' });
});
function onCardClick(idx: number): void {
// Toggle off if the user re-clicks the already-active card.
if (focused.index === idx) {
setFocused(null, 'strip');
} else {
setFocused(idx, 'strip');
}
}
// Step to the next/previous photo that's in the active stage (skips photos
// hidden by stage scoping).
function advance(direction: -1 | 1): void {
if (images.length === 0) return;
let i = focused.index === null ? (direction === 1 ? -1 : images.length) : focused.index;
i += direction;
while (i >= 0 && i < images.length) {
if (inActiveStage(i)) {
setFocused(i, 'strip');
return;
}
i += direction;
}
}
const canPrev = $derived.by(() => {
if (focused.index === null) return false;
for (let i = focused.index - 1; i >= 0; i--) if (inActiveStage(i)) return true;
return false;
});
const canNext = $derived.by(() => {
const start = focused.index === null ? -1 : focused.index;
for (let i = start + 1; i < images.length; i++) if (inActiveStage(i)) return true;
return false;
});
function onKey(e: KeyboardEvent): void {
if (images.length === 0) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
advance(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
advance(1);
}
}
</script>
{#if images.length > 0}
<section class="strip-section" aria-label="Bilder der Tour">
<header class="strip-header">
<h2 class="strip-title">Bildstrecke</h2>
<span class="strip-hint">
<MapPin size={14} strokeWidth={1.75} aria-hidden="true" />
Klicken zeigt die Position auf der Karte
</span>
</header>
<div class="strip-frame">
<button
type="button"
class="chev chev-left"
aria-label="Vorheriges Bild"
disabled={!canPrev}
onclick={() => advance(-1)}
>
<ChevronLeft size={20} strokeWidth={2.25} aria-hidden="true" />
</button>
<div
class="strip-scroll"
bind:this={scrollEl}
onkeydown={onKey}
role="listbox"
tabindex="0"
aria-label="Tourenfotos chronologisch"
>
{#each images as ip, i (ip.src)}
{@const elapsed =
ip.timestamp != null && startTimestamp != null
? formatElapsed(ip.timestamp - startTimestamp)
: null}
{@const active = focused.index === i}
{#if inActiveStage(i)}
<div class="card-wrap" class:active bind:this={cardEls[i]}>
<button
type="button"
class="card"
class:private={ip.visibility === 'private'}
onclick={() => onCardClick(i)}
aria-label={`Foto ${i + 1} von ${images.length}${elapsed ? `, nach ${elapsed}` : ''}`}
role="option"
aria-selected={active}
>
<img src={ip.thumbnail} alt={ip.alt} loading="lazy" decoding="async" />
<div class="overlay">
{#if elapsed}
<span class="chip-elapsed">nach {elapsed}</span>
{/if}
<span class="chip-index">{i + 1}/{images.length}</span>
</div>
{#if ip.visibility === 'private'}
<span class="badge-private" aria-label="Privat">
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
privat
</span>
{/if}
</button>
<button
type="button"
class="expand"
aria-label={`Foto ${i + 1} im Vollbild öffnen`}
title="Vollbild"
onclick={() => openLightbox(i)}
>
<Expand size={15} strokeWidth={2} aria-hidden="true" />
</button>
</div>
{/if}
{/each}
</div>
<button
type="button"
class="chev chev-right"
aria-label="Nächstes Bild"
disabled={!canNext}
onclick={() => advance(1)}
>
<ChevronRight size={20} strokeWidth={2.25} aria-hidden="true" />
</button>
</div>
</section>
{#if lightboxIndex !== null}
{@const ip = images[lightboxIndex]}
{@const elapsed =
ip.timestamp != null && startTimestamp != null
? formatElapsed(ip.timestamp - startTimestamp)
: null}
<div
class="lightbox"
role="dialog"
aria-modal="true"
aria-label={`Foto ${lightboxIndex + 1} von ${images.length}`}
transition:fade={{ duration: 150 }}
>
<button class="lb-backdrop" aria-label="Schließen" onclick={closeLightbox}></button>
<button
class="lb-btn lb-close"
aria-label="Schließen"
bind:this={closeBtn}
onclick={closeLightbox}
>
<X size={22} strokeWidth={2} aria-hidden="true" />
</button>
{#if lightboxIndex > 0}
<button class="lb-btn lb-prev" aria-label="Vorheriges Bild" onclick={() => lightboxStep(-1)}>
<ChevronLeft size={26} strokeWidth={2.25} aria-hidden="true" />
</button>
{/if}
{#if lightboxIndex < images.length - 1}
<button class="lb-btn lb-next" aria-label="Nächstes Bild" onclick={() => lightboxStep(1)}>
<ChevronRight size={26} strokeWidth={2.25} aria-hidden="true" />
</button>
{/if}
<figure class="lb-figure">
<img src={ip.src} alt={ip.alt} />
<figcaption class="lb-caption">
<span class="lb-count">{lightboxIndex + 1} / {images.length}</span>
{#if elapsed}<span class="lb-elapsed">nach {elapsed}</span>{/if}
{#if ip.alt}<span class="lb-alt">{ip.alt}</span>{/if}
</figcaption>
</figure>
</div>
{/if}
{/if}
<style>
.strip-section {
margin-top: 1.25rem;
padding-top: 0.5rem;
}
.strip-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.6rem;
}
.strip-title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.strip-hint {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
.strip-hint :global(svg) {
color: var(--color-primary);
}
@media (max-width: 560px) {
.strip-hint {
display: none;
}
}
.strip-frame {
position: relative;
}
.strip-scroll {
display: flex;
gap: 0.75rem;
overflow-x: auto;
overflow-y: visible;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
/* Vertical padding makes room for the lifted active card's shadow. */
padding: 1rem 0.25rem 1.5rem;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
-webkit-overflow-scrolling: touch;
}
.strip-scroll:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 4px;
border-radius: var(--radius-md);
}
/* The wrapper is the flex item: it carries the size, scroll-snap and the
* lift/scale transform. The card button and the expand button live inside
* it as siblings (a button can't be nested in a button). */
.card-wrap {
position: relative;
flex: 0 0 auto;
width: 232px;
scroll-snap-align: center;
border-radius: var(--radius-lg);
transform: translateY(0) scale(1);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.card-wrap:hover,
.card-wrap:focus-within {
transform: translateY(-2px) scale(1.02);
}
/* Active card stands out via a much heavier, tinted drop shadow rather
* than dimming everything else — keeps every photo legible. */
.card-wrap.active {
transform: translateY(-6px) scale(1.05);
}
.card {
position: relative;
display: block;
width: 100%;
padding: 0;
border: 0;
background: var(--color-surface);
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: box-shadow 220ms ease;
}
.card-wrap:hover .card,
.card-wrap:focus-within .card {
box-shadow: var(--shadow-md);
}
.card:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.card-wrap.active .card {
box-shadow:
0 18px 32px -8px color-mix(in oklab, var(--color-primary) 55%, transparent),
0 6px 14px -6px rgb(0 0 0 / 0.25);
}
/* Fullscreen trigger — a circular badge in the top-right of each card.
* Hidden until the card is hovered/focused/active (always shown on touch
* devices, which have no hover). */
.expand {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 3;
display: grid;
place-items: center;
width: 30px;
height: 30px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgb(0 0 0 / 0.5);
color: #fff;
cursor: pointer;
opacity: 0;
transform: scale(0.85);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition:
opacity var(--transition-fast),
transform var(--transition-fast),
background var(--transition-fast);
}
.card-wrap:hover .expand,
.card-wrap:focus-within .expand,
.card-wrap.active .expand {
opacity: 1;
transform: scale(1);
}
.expand:hover {
background: rgb(0 0 0 / 0.72);
transform: scale(1.1);
}
.expand:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
opacity: 1;
transform: scale(1);
}
@media (hover: none) {
.expand {
opacity: 1;
transform: scale(1);
}
}
.card img {
display: block;
width: 100%;
/* 3:2 — a touch shorter than the old 4:3 so the strip sits compactly
* above the stats row without dominating the page. */
aspect-ratio: 3 / 2;
object-fit: cover;
background: var(--color-bg-elevated);
}
.overlay {
position: absolute;
inset: auto 0 0 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0.5rem 0.6rem;
background: linear-gradient(to top, rgb(0 0 0 / 0.55), transparent);
color: #fff;
pointer-events: none;
}
.chip-elapsed {
font-size: 0.78rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
padding: 0.2rem 0.55rem;
border-radius: var(--radius-pill);
background: rgb(0 0 0 / 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
letter-spacing: 0.01em;
}
.chip-index {
font-size: 0.72rem;
opacity: 0.85;
font-variant-numeric: tabular-nums;
}
.badge-private {
position: absolute;
top: 0.5rem;
left: 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.18rem 0.5rem;
border-radius: var(--radius-pill);
background: rgb(0 0 0 / 0.55);
color: #fff;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.chev {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-primary);
box-shadow: var(--shadow-md);
cursor: pointer;
z-index: 2;
transition:
transform var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast),
opacity var(--transition-fast);
}
.chev-left {
left: -8px;
}
.chev-right {
right: -8px;
}
.chev:hover:not(:disabled) {
color: var(--color-primary);
transform: translateY(-50%) scale(1.08);
box-shadow: var(--shadow-hover);
}
.chev:disabled {
opacity: 0;
pointer-events: none;
}
@media (max-width: 560px) {
.card-wrap {
width: 180px;
}
.chev {
width: 32px;
height: 32px;
}
.chev-left {
left: -4px;
}
.chev-right {
right: -4px;
}
}
@media (prefers-reduced-motion: reduce) {
.card-wrap,
.card,
.strip-scroll,
.chev,
.expand {
transition: none;
scroll-behavior: auto;
}
}
/* ── Fullscreen lightbox ─────────────────────────────────────────────── */
.lightbox {
position: fixed;
inset: 0;
z-index: 9000;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgb(0 0 0 / 0.92);
}
.lb-backdrop {
position: absolute;
inset: 0;
border: 0;
margin: 0;
padding: 0;
background: transparent;
cursor: zoom-out;
}
.lb-figure {
position: relative;
z-index: 1;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
max-width: 92vw;
max-height: 90vh;
}
.lb-figure img {
max-width: 92vw;
max-height: 82vh;
object-fit: contain;
border-radius: var(--radius-md);
box-shadow: 0 12px 48px rgb(0 0 0 / 0.55);
}
.lb-caption {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.4rem 0.85rem;
max-width: 92vw;
color: rgb(255 255 255 / 0.88);
font-size: 0.85rem;
text-align: center;
}
.lb-count {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.lb-elapsed {
color: rgb(255 255 255 / 0.7);
font-variant-numeric: tabular-nums;
}
.lb-alt {
color: rgb(255 255 255 / 0.6);
flex-basis: 100%;
}
.lb-btn {
position: absolute;
z-index: 2;
display: grid;
place-items: center;
width: 46px;
height: 46px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 0.12);
color: #fff;
cursor: pointer;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
transition:
background var(--transition-fast),
transform var(--transition-fast);
}
.lb-btn:hover {
background: rgb(255 255 255 / 0.24);
transform: scale(1.08);
}
.lb-btn:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
.lb-close {
top: 1rem;
right: 1rem;
}
.lb-prev {
left: 1rem;
top: 50%;
transform: translateY(-50%);
}
.lb-next {
right: 1rem;
top: 50%;
transform: translateY(-50%);
}
.lb-prev:hover,
.lb-next:hover {
transform: translateY(-50%) scale(1.08);
}
@media (max-width: 560px) {
.lb-btn {
width: 40px;
height: 40px;
}
.lb-close {
top: 0.6rem;
right: 0.6rem;
}
.lb-prev {
left: 0.5rem;
}
.lb-next {
right: 0.5rem;
}
}
</style>
@@ -0,0 +1,191 @@
<script lang="ts">
// Stage switcher styled as a hut-to-hut itinerary line: a leading "Alle"
// pill, then numbered nodes joined by thin connectors. The active stage's
// node glows in the accent and its name/distance shows alongside. Light and
// in-flow (no boxed/blurred bar) — writes the shared stageStore.
import type { HikeStage } from '$types/hikes';
import { stage, setActiveStage } from './stageStore.svelte';
interface Props {
stages: HikeStage[];
}
const { stages }: Props = $props();
const active = $derived(stage.active);
const totalKm = $derived(stages.reduce((a, s) => a + s.distanceKm, 0));
</script>
<nav class="stepper" aria-label="Etappen">
<button
type="button"
class="all"
class:active={active === null}
aria-pressed={active === null}
onclick={() => setActiveStage(null)}
>
Alle
</button>
<ol class="line">
{#each stages as s, i (i)}
{#if i > 0}
<li class="connector" class:lit={active === null} aria-hidden="true"></li>
{/if}
<li>
<button
type="button"
class="node"
class:active={active === i}
class:lit={active === null}
aria-pressed={active === i}
aria-label={`Etappe ${i + 1}: ${s.name}`}
title={s.name}
onclick={() => setActiveStage(i)}
>
{i + 1}
</button>
</li>
{/each}
</ol>
<p class="label" aria-live="polite">
{#if active === null}
<span class="title">Alle Etappen</span>
<span class="dist">{totalKm.toFixed(1)} km</span>
{:else}
<span class="kicker">Etappe {active + 1}</span>
<span class="title">{stages[active].name}</span>
<span class="dist">{stages[active].distanceKm.toFixed(1)} km</span>
{/if}
</p>
</nav>
<style>
.stepper {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 1rem;
/* (a) breathing room below the full-bleed hero map; horizontal inset
* matches the other detail sections. */
margin-top: 1.75rem;
padding: 0 1rem;
}
.all {
flex: 0 0 auto;
appearance: none;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-fast), background-color var(--transition-fast),
border-color var(--transition-fast);
}
.all:hover {
color: var(--color-text-primary);
border-color: var(--color-border-hover);
}
.all.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
.line {
display: flex;
align-items: center;
gap: 0;
list-style: none;
margin: 0;
padding: 0;
}
.connector {
width: 1.75rem;
height: 2px;
flex: 0 0 auto;
border-radius: 2px;
background: var(--color-border);
transition: background-color var(--transition-fast);
}
.connector.lit {
background: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
}
.node {
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
appearance: none;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
border: 1.5px solid var(--color-border);
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--transition-fast), background-color var(--transition-fast),
border-color var(--transition-fast), scale var(--transition-fast),
box-shadow var(--transition-fast);
}
.node:hover {
scale: 1.1;
border-color: var(--color-primary);
color: var(--color-text-primary);
}
/* "Alle" selected: whole line subtly lit so it reads as the full route. */
.node.lit {
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
color: var(--color-text-primary);
}
.node.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.label {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.1rem 0.5rem;
margin: 0;
min-width: 0;
}
.kicker {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-tertiary);
}
.title {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-primary);
}
.dist {
font-size: 0.82rem;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,776 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { SvelteSet } from 'svelte/reactivity';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import X from '@lucide/svelte/icons/x';
import RangeSlider from './RangeSlider.svelte';
import ChipTypeahead from './ChipTypeahead.svelte';
import Toggle from '$lib/components/Toggle.svelte';
import { resolveHikeArea, type HikeArea } from '$lib/hikes/hikeArea';
import {
hikeFilterBounds,
DISTANCE_STEP,
DURATION_STEP,
ELEVATION_STEP
} from '$lib/hikes/filterBounds';
import type { Difficulty, HikeManifestEntry } from '$types/hikes';
export type HikesFilter = {
minDistanceKm: number;
maxDistanceKm: number;
minDurationMin: number;
maxDurationMin: number;
minGainM: number;
maxGainM: number;
minLossM: number;
maxLossM: number;
difficulties: SvelteSet<Difficulty>;
regions: SvelteSet<string>;
/** Namespaced area values — canton (CH) or country (abroad). See
* {@link resolveHikeArea}. */
areas: SvelteSet<string>;
tags: SvelteSet<string>;
/** Show only hikes whose recommended season covers the current month. */
inSeasonOnly: boolean;
};
interface Props {
hikes: HikeManifestEntry[];
filter: HikesFilter;
/** Hikes passing the current filter — shown in the bar summary. */
resultCount: number;
/** Total hikes before filtering. */
totalCount: number;
/** Summed distance / ascent over the filtered subset (already rounded). */
totalKm: number;
totalGain: number;
}
const DIFFICULTIES: Difficulty[] = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6'];
const { hikes, filter, resultCount, totalCount, totalKm, totalGain }: Props = $props();
// Collapsed-by-default: the bar is just a summary + active-filter chips +
// a trigger until the user opens the control panel. Keeps the listing's
// vertical rhythm clean — the filters only take space when wanted.
let open = $state(false);
let root = $state<HTMLElement>();
// Range-slider track extents, derived from the data (see filterBounds.ts —
// the same helper seeds the page's default filter state).
const bounds = $derived(hikeFilterBounds(hikes));
const regions = $derived.by(() => {
const seen: Record<string, true> = {};
const out: string[] = [];
for (const h of hikes) {
if (h.region && !seen[h.region]) {
seen[h.region] = true;
out.push(h.region);
}
}
return out.sort((a, b) => a.localeCompare(b));
});
// Geographic areas present in the data: a Swiss hike contributes its canton,
// a hike abroad its country. Deduped by namespaced value; cantons listed
// first (alphabetical), then countries (alphabetical).
const areaList = $derived.by(() => {
const map = new Map<string, HikeArea>();
for (const h of hikes) {
const a = resolveHikeArea(h.canton, h.country);
if (a && !map.has(a.value)) map.set(a.value, a);
}
return [...map.values()].sort((a, b) =>
a.kind === b.kind ? a.label.localeCompare(b.label) : a.kind === 'canton' ? -1 : 1
);
});
const areaValues = $derived(areaList.map((a) => a.value));
const areaByValue = $derived(new Map(areaList.map((a) => [a.value, a])));
// Tags sorted by usage frequency (most-used first), alphabetical for
// ties. Frequency ordering surfaces broadly-applicable filters like
// "winter" or "easy" at the head of the list, where they're most
// useful for narrowing the listing.
const tags = $derived.by(() => {
const counts = new Map<string, number>();
for (const h of hikes) {
for (const t of h.tags ?? []) {
counts.set(t, (counts.get(t) ?? 0) + 1);
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([t]) => t);
});
function fmtDuration(min: number) {
return `${Math.floor(min / 60)}h ${min % 60}m`;
}
// Compact chip label for a narrowed range: "≤ hi" / "≥ lo" / "lohi",
// suppressing whichever end still sits at its data bound.
function rangeLabel(
lo: number,
hi: number,
b: { min: number; max: number },
fmt: (v: number) => string,
unit: string
) {
const u = unit ? ` ${unit}` : '';
const loNarrowed = lo > b.min;
const hiNarrowed = hi < b.max;
if (loNarrowed && hiNarrowed) return `${fmt(lo)}${fmt(hi)}${u}`;
if (hiNarrowed) return `≤ ${fmt(hi)}${u}`;
return `≥ ${fmt(lo)}${u}`;
}
// SAC trail-sign colour band — matches the card badges (T1 yellow
// Wegweiser, T2/T3 red-white Bergweg, T4T6 blue-white Alpinweg). Used
// for the small colour dot on each difficulty toggle.
function sacBand(d: Difficulty): 'yellow' | 'red' | 'blue' {
if (d === 'T1') return 'yellow';
if (d === 'T2' || d === 'T3') return 'red';
return 'blue';
}
// Active filters, flattened into removable chips for the collapsed bar.
// A range counts as "active" only when narrowed below its data ceiling.
type Chip = { key: string; label: string; icon?: string; clear: () => void };
const chips = $derived.by<Chip[]>(() => {
const out: Chip[] = [];
const { distance, duration, gain, loss } = bounds;
if (filter.inSeasonOnly)
out.push({ key: 'season', label: 'In Saison', clear: () => (filter.inSeasonOnly = false) });
if (filter.minDistanceKm > distance.min || filter.maxDistanceKm < distance.max)
out.push({
key: 'dist',
label: rangeLabel(filter.minDistanceKm, filter.maxDistanceKm, distance, (v) => `${v}`, 'km'),
clear: () => {
filter.minDistanceKm = distance.min;
filter.maxDistanceKm = distance.max;
}
});
if (filter.minDurationMin > duration.min || filter.maxDurationMin < duration.max)
out.push({
key: 'dur',
label: rangeLabel(filter.minDurationMin, filter.maxDurationMin, duration, fmtDuration, ''),
clear: () => {
filter.minDurationMin = duration.min;
filter.maxDurationMin = duration.max;
}
});
if (filter.minGainM > gain.min || filter.maxGainM < gain.max)
out.push({
key: 'gain',
label: `↑ ${rangeLabel(filter.minGainM, filter.maxGainM, gain, (v) => `${v}`, 'm')}`,
clear: () => {
filter.minGainM = gain.min;
filter.maxGainM = gain.max;
}
});
if (filter.minLossM > loss.min || filter.maxLossM < loss.max)
out.push({
key: 'loss',
label: `↓ ${rangeLabel(filter.minLossM, filter.maxLossM, loss, (v) => `${v}`, 'm')}`,
clear: () => {
filter.minLossM = loss.min;
filter.maxLossM = loss.max;
}
});
for (const d of DIFFICULTIES)
if (filter.difficulties.has(d))
out.push({ key: `d-${d}`, label: d, clear: () => filter.difficulties.delete(d) });
for (const r of filter.regions)
out.push({ key: `r-${r}`, label: r, clear: () => filter.regions.delete(r) });
for (const value of filter.areas) {
const a = areaByValue.get(value);
out.push({
key: `a-${value}`,
label: a?.label ?? value,
icon: a?.iconUrl,
clear: () => filter.areas.delete(value)
});
}
for (const t of filter.tags)
out.push({ key: `t-${t}`, label: `#${t}`, clear: () => filter.tags.delete(t) });
return out;
});
const activeCount = $derived(chips.length);
function toggleDifficulty(d: Difficulty) {
if (filter.difficulties.has(d)) filter.difficulties.delete(d);
else filter.difficulties.add(d);
}
function toggleRegion(r: string) {
if (filter.regions.has(r)) filter.regions.delete(r);
else filter.regions.add(r);
}
function toggleArea(value: string) {
if (filter.areas.has(value)) filter.areas.delete(value);
else filter.areas.add(value);
}
function toggleTag(t: string) {
if (filter.tags.has(t)) filter.tags.delete(t);
else filter.tags.add(t);
}
function resetFilters() {
filter.minDistanceKm = bounds.distance.min;
filter.maxDistanceKm = bounds.distance.max;
filter.minDurationMin = bounds.duration.min;
filter.maxDurationMin = bounds.duration.max;
filter.minGainM = bounds.gain.min;
filter.maxGainM = bounds.gain.max;
filter.minLossM = bounds.loss.min;
filter.maxLossM = bounds.loss.max;
filter.difficulties.clear();
filter.regions.clear();
filter.areas.clear();
filter.tags.clear();
filter.inSeasonOnly = false;
}
// Light-dismiss: close the panel on outside click or Escape. Only wired
// up while open so the listeners aren't carried for the whole session.
$effect(() => {
if (!open) return;
const onPointer = (e: PointerEvent) => {
if (root && !root.contains(e.target as Node)) open = false;
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') open = false;
};
document.addEventListener('pointerdown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onPointer);
document.removeEventListener('keydown', onKey);
};
});
</script>
<div class="filter-bar" bind:this={root}>
<p class="summary">
<span class="count"><strong>{resultCount}</strong> von {totalCount} Touren</span>
{#if resultCount > 0}
<span class="dot" aria-hidden="true">·</span>
<span class="stat">{totalKm.toLocaleString('de-CH')} km</span>
<span class="dot" aria-hidden="true">·</span>
<span class="stat">{totalGain.toLocaleString('de-CH')} hm</span>
{/if}
</p>
{#if activeCount > 0}
<div class="active-chips" aria-label="Aktive Filter">
{#each chips as chip (chip.key)}
<button type="button" class="chip" onclick={chip.clear}>
{#if chip.icon}<img class="chip-emblem" src={chip.icon} alt="" aria-hidden="true" />{/if}<span
class="chip-label">{chip.label}</span>
<X size={13} strokeWidth={2} aria-label="entfernen" />
</button>
{/each}
<button type="button" class="clear-all" onclick={resetFilters}>Alle löschen</button>
</div>
{/if}
<button
type="button"
class="filter-toggle"
class:open
aria-expanded={open}
aria-controls="filter-panel"
onclick={() => (open = !open)}
>
<SlidersHorizontal size={16} strokeWidth={1.75} aria-hidden="true" />
<span>Filter</span>
{#if activeCount > 0}<span class="badge">{activeCount}</span>{/if}
<ChevronDown class="chev" size={16} strokeWidth={1.75} aria-hidden="true" />
</button>
{#if open}
<div id="filter-panel" class="panel" transition:slide={{ duration: 200 }}>
<div class="season-row">
<Toggle bind:checked={filter.inSeasonOnly} label="Nur Touren in der aktuellen Saison" />
</div>
<hr class="divider" />
<div class="ranges">
<RangeSlider
label="Distanz"
min={bounds.distance.min}
max={bounds.distance.max}
step={DISTANCE_STEP}
bind:low={filter.minDistanceKm}
bind:high={filter.maxDistanceKm}
format={(v) => `${v} km`}
/>
<RangeSlider
label="Dauer"
min={bounds.duration.min}
max={bounds.duration.max}
step={DURATION_STEP}
bind:low={filter.minDurationMin}
bind:high={filter.maxDurationMin}
format={fmtDuration}
/>
<RangeSlider
label="Aufstieg"
min={bounds.gain.min}
max={bounds.gain.max}
step={ELEVATION_STEP}
bind:low={filter.minGainM}
bind:high={filter.maxGainM}
format={(v) => `${v} m`}
/>
<RangeSlider
label="Abstieg"
min={bounds.loss.min}
max={bounds.loss.max}
step={ELEVATION_STEP}
bind:low={filter.minLossM}
bind:high={filter.maxLossM}
format={(v) => `${v} m`}
/>
</div>
<hr class="divider" />
<fieldset>
<legend>Schwierigkeit (SAC)</legend>
<div class="sac-grid">
{#each DIFFICULTIES as d (d)}
<button
type="button"
class="sac-toggle"
class:active={filter.difficulties.has(d)}
aria-pressed={filter.difficulties.has(d)}
aria-label="SAC-Schwierigkeit {d}"
onclick={() => toggleDifficulty(d)}
>
<span class="sac-marker sac-marker-{sacBand(d)}">{d}</span>
</button>
{/each}
</div>
</fieldset>
{#if regions.length > 0}
<fieldset>
<legend>Region</legend>
<div class="pills">
{#each regions as r (r)}
<button
type="button"
class="pill"
class:active={filter.regions.has(r)}
onclick={() => toggleRegion(r)}
>{r}</button>
{/each}
</div>
</fieldset>
{/if}
{#if areaList.length > 0}
<fieldset>
<legend>Kanton / Land</legend>
<ChipTypeahead
options={areaValues}
selected={filter.areas}
onToggle={toggleArea}
placeholder="Kanton oder Land eingeben oder auswählen…"
iconFor={(value) => areaByValue.get(value)?.iconUrl}
labelFor={(value) => areaByValue.get(value)?.label ?? value}
/>
</fieldset>
{/if}
{#if tags.length > 0}
<fieldset>
<legend>Schlagwörter</legend>
<ChipTypeahead
options={tags}
selected={filter.tags}
onToggle={toggleTag}
hash
placeholder="Schlagwort eingeben oder auswählen…"
/>
</fieldset>
{/if}
<div class="panel-foot">
<button type="button" class="reset" onclick={resetFilters} disabled={activeCount === 0}>
Zurücksetzen
</button>
</div>
</div>
{/if}
</div>
<style>
.filter-bar {
position: relative;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 0.5rem 0.6rem 0.5rem 1rem;
box-shadow: var(--shadow-sm);
}
.summary {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0;
flex: 0 1 auto;
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.count strong {
color: var(--color-text-primary);
font-weight: 700;
}
.stat {
font-size: 0.82rem;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.dot {
color: var(--color-text-muted);
}
/* Active filters surfaced inline so the user always sees what's narrowing
* the listing without opening the panel; each chip removes its own facet. */
.active-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
flex: 1 1 auto;
min-width: 0;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
appearance: none;
font: inherit;
font-size: 0.78rem;
padding: 0.18rem 0.5rem 0.18rem 0.65rem;
border-radius: var(--radius-pill);
cursor: pointer;
color: var(--color-text-primary);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-primary) 32%, var(--color-border));
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.chip:hover {
background: color-mix(in srgb, var(--color-primary) 22%, var(--color-surface));
}
.chip :global(svg) {
opacity: 0.6;
transition: opacity var(--transition-fast);
}
.chip:hover :global(svg) {
opacity: 1;
}
.chip-label {
font-variant-numeric: tabular-nums;
}
/* Canton coat-of-arms inside an active-filter chip. */
.chip-emblem {
width: 12px;
height: 15px;
object-fit: contain;
flex: 0 0 auto;
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.18));
}
.clear-all {
appearance: none;
background: transparent;
border: 0;
font: inherit;
font-size: 0.78rem;
color: var(--color-text-tertiary);
cursor: pointer;
padding: 0.18rem 0.3rem;
text-decoration: underline;
text-underline-offset: 2px;
}
.clear-all:hover {
color: var(--color-text-primary);
}
.filter-toggle {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.4rem;
appearance: none;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-primary);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
padding: 0.45rem 0.8rem;
border-radius: var(--radius-pill);
cursor: pointer;
transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.filter-toggle:hover {
background: var(--color-bg-elevated);
}
.filter-toggle.open {
border-color: var(--color-primary);
background: var(--color-bg-elevated);
}
.filter-toggle :global(.chev) {
transition: rotate var(--transition-normal);
}
.filter-toggle.open :global(.chev) {
rotate: 180deg;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.2rem;
height: 1.2rem;
padding: 0 0.35rem;
background: var(--color-primary);
color: var(--color-text-on-primary);
border-radius: var(--radius-pill);
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/* Expands in-flow on its own full-width row inside the bar, so opening it
* pushes the card grid down (accordion) rather than overlaying it. The
* top border separates it from the summary row; both it and the vertical
* padding are animated by the `slide` transition. */
.panel {
width: 100%;
display: flex;
flex-direction: column;
gap: 1.1rem;
margin-top: 0.6rem;
padding-top: 1.1rem;
border-top: 1px solid var(--color-border);
}
.ranges {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.1rem 1.75rem;
}
.divider {
height: 1px;
border: 0;
margin: 0;
background: var(--color-border);
}
fieldset {
border: 0;
padding: 0;
margin: 0;
}
legend {
display: block;
margin-bottom: 0.5rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-tertiary);
}
.pills {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.pill {
appearance: none;
border: 1px solid var(--color-border);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.7rem;
border-radius: var(--radius-pill);
cursor: pointer;
transition: scale var(--transition-fast), background-color var(--transition-fast),
color var(--transition-fast);
}
.pill:hover {
scale: 1.05;
background: var(--color-bg-elevated);
}
.pill.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
/* Difficulty toggles render the actual SAC trail-sign markers (same shapes
* as the hike cards): T1 yellow Wegweiser arrow, T2/T3 white-red-white
* Bergweg, T4T6 white-blue-white Alpinweg. No container chrome — boxing
* the irregular arrow looked off. Selection is the sign itself "lighting
* up": unselected signs are dimmed + desaturated, the selected ones snap
* to full colour, scale up and lift with a shadow. */
.sac-grid {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.sac-toggle {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: none;
border: 0;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0.45;
filter: grayscale(0.6);
transition: scale var(--transition-fast), opacity var(--transition-fast),
filter var(--transition-fast);
}
.sac-toggle:hover {
opacity: 0.85;
filter: grayscale(0.1);
scale: 1.08;
}
.sac-toggle.active {
opacity: 1;
filter: none;
scale: 1.08;
}
.sac-toggle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Lift only the selected signs so they read as raised above the dimmed
* ones. (Applied to the marker, not the toggle, so it survives the
* toggle's `filter: none`.) */
.sac-toggle.active .sac-marker {
filter: drop-shadow(0 2px 5px rgb(0 0 0 / 0.35));
}
.sac-marker {
display: inline-flex;
align-items: center;
justify-content: center;
height: 26px;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
}
.sac-marker-yellow {
width: 44px;
color: #1a1a1a;
background: #f5a623;
clip-path: polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%);
/* Text sits in the rectangular left portion (arrow tip is the right 25%). */
justify-content: flex-start;
padding-left: 0.55rem;
}
.sac-marker-red,
.sac-marker-blue {
width: 32px;
color: #fff;
text-shadow: 0 1px 1px rgb(0 0 0 / 0.45);
border-radius: 2px;
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.18);
}
.sac-marker-red {
background: linear-gradient(to bottom, #fff 0 25%, #dc1d2a 25% 75%, #fff 75% 100%);
}
.sac-marker-blue {
background: linear-gradient(to bottom, #fff 0 25%, #2965c8 25% 75%, #fff 75% 100%);
}
.panel-foot {
display: flex;
justify-content: flex-end;
}
.reset {
appearance: none;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font: inherit;
font-size: 0.85rem;
padding: 0.4rem 0.9rem;
border-radius: var(--radius-pill);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.reset:hover:not(:disabled) {
background: var(--color-bg-elevated);
}
.reset:disabled {
opacity: 0.45;
cursor: default;
}
@media (max-width: 560px) {
.ranges {
grid-template-columns: 1fr;
}
/* Give the trigger its own line so the summary + chips aren't squeezed. */
.summary {
flex: 1 1 100%;
}
}
</style>
@@ -0,0 +1,599 @@
<script lang="ts">
import type { Attachment } from 'svelte/attachments';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { sacTrailColor } from '$lib/data/sacColors';
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
import { isSwissRegion } from '$lib/hikes/hikeArea';
import type { HikeManifestEntry } from '$types/hikes';
import Map from '@lucide/svelte/icons/map';
import Satellite from '@lucide/svelte/icons/satellite';
import Landmark from '@lucide/svelte/icons/landmark';
import Layers from '@lucide/svelte/icons/layers';
import Locate from '@lucide/svelte/icons/locate';
import LocateOff from '@lucide/svelte/icons/locate-off';
import Maximize2 from '@lucide/svelte/icons/maximize-2';
interface Props {
hikes: HikeManifestEntry[];
/** Initial map centre `[lat, lng]`. When provided alongside
* `initialZoom`, the map opens with `setView(center, zoom)` instead
* of `fitBounds(union)` — used by the index page to align Leaflet's
* first paint with the SSR-rendered static overview hero. */
initialCenter?: [number, number];
initialZoom?: number;
/** Fires once the schematic tile layer's first batch of tiles has
* finished loading — i.e. the map is visually complete. The page
* uses this to fade out the SSR-rendered static hero. */
onReady?: () => void;
}
const { hikes, initialCenter, initialZoom, onReady }: Props = $props();
// When every displayed hike is in a swisstopo region (CH/LI), the schematic
// can use swisstopo's z19; with a hike abroad the global fallback is shallower
// so we cap a touch lower (still generous — the overview is a finder).
const allSwiss = $derived(hikes.every((h) => isSwissRegion(h.canton, h.country)));
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = $derived({
schematic: { label: 'Karte', icon: Map, maxZoom: allSwiss ? 19 : 18 },
aerial: { label: 'Luftbild', icon: Satellite, maxZoom: 19 },
dufour: { label: 'Dufour (1864)', icon: Landmark, maxZoom: 16 }
});
const GPS_STORAGE_KEY = 'hikes:gpsEnabled';
let baseLayer = $state<BaseLayer>('schematic');
let layerMenuOpen = $state(false);
let enableUserLocation = $state(false);
let locationError = $state<string | null>(null);
// Re-fit callback wired up once Leaflet + bounds are alive inside the
// attachment. Null hides the button.
let recenterMap = $state<(() => void) | null>(null);
$effect(() => {
if (typeof window === 'undefined') return;
if (window.localStorage.getItem(GPS_STORAGE_KEY) === '1') enableUserLocation = true;
});
$effect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(GPS_STORAGE_KEY, enableUserLocation ? '1' : '0');
});
// Close the layer popover on outside click. The opening click on the
// button calls stopPropagation so this never sees the click that opened it.
$effect(() => {
if (!layerMenuOpen) return;
function onAway(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target && !target.closest('.layer-menu')) layerMenuOpen = false;
}
window.addEventListener('click', onAway);
return () => window.removeEventListener('click', onAway);
});
function toggleLocation() {
if (enableUserLocation) {
enableUserLocation = false;
locationError = null;
return;
}
if (typeof window === 'undefined') return;
const hasTauri = '__TAURI_INTERNALS__' in window;
const hasWebGeo = 'geolocation' in navigator;
if (!hasTauri && !hasWebGeo) {
locationError = 'Geolocation steht in diesem Browser nicht zur Verfügung.';
return;
}
locationError = null;
enableUserLocation = true;
}
const mapAttachment: Attachment<HTMLElement> = (node) => {
let cancelled = false;
let cleanup: (() => void) | undefined;
(async () => {
const L = await import('leaflet');
if (cancelled || !node.isConnected) return;
// `tolerance` widens the canvas renderer's hit-test radius around
// every polyline (hit = weight/2 + tolerance), so a route can be
// hovered/clicked from a comfortable margin instead of demanding a
// pixel-perfect click on the 4 px line.
const map = L.map(node, {
// On-map attribution control removed for a cleaner frame.
// NOTE: swisstopo's tile licence requires their credit to appear;
// the /hikes page currently shows no other swisstopo attribution.
attributionControl: false,
zoomControl: true,
preferCanvas: true,
renderer: L.canvas({ tolerance: 12 })
});
// Sensible default centre (mid-Switzerland) while the polyline
// layer is built up; `fitBounds` below overrides it once the
// union bounds are known. If the caller passed a pre-rendered
// hero pose, use that instead so Leaflet lands aligned with the
// static image on first paint.
if (initialCenter && typeof initialZoom === 'number') {
map.setView(initialCenter, initialZoom, { animate: false });
} else {
map.setView([46.8, 8.3], 8);
}
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
schematic: L.tileLayer(TILE_URL.karte, {
maxZoom: LAYER_DEFS.schematic.maxZoom,
minZoom: 7,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
}),
aerial: L.tileLayer(TILE_URL.luftbild, {
maxZoom: LAYER_DEFS.aerial.maxZoom,
minZoom: 7,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
}),
dufour: L.tileLayer(TILE_URL.dufour, {
maxZoom: LAYER_DEFS.dufour.maxZoom,
minZoom: 7,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
})
};
tileLayers.schematic.addTo(map);
let currentBase: BaseLayer = 'schematic';
// Forward-declared so the tile-load handover handler below can
// close over it; populated once the polyline loop has built the
// union bounds.
let initialBounds: ReturnType<typeof L.latLngBounds> | null = null;
// First-paint handover: fire `onReady` once the schematic tile
// layer's initial batch loads so the static hero can fade out.
// The map already opened at the static pose via setView (see
// the initialCenter branch below), so no extra animation is
// needed — and `flyToBounds(union)` here used to cause a
// visible wobble on hikes whose union bbox sits at an integer-
// zoom boundary, where the static's fit and Leaflet's runtime
// fit disagree by one zoom step. Mirrors the same fix in
// `HikeMap.svelte`.
tileLayers.schematic.once('load', () => {
onReady?.();
});
// One polyline per hike, sourced from the manifest's already-
// simplified previewPolyline (≤150 points each). The layer is
// re-populated on every `hikes` prop change (see the $effect
// below) so toggling filters updates the visible routes — and
// re-fits the camera to the new union bounds.
const layer = L.layerGroup().addTo(map);
function renderPolylines(): boolean {
layer.clearLayers();
const b = L.latLngBounds([]);
for (const hike of hikes) {
if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue;
const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]);
const color = sacTrailColor(hike.difficulty);
// Multi-day hikes with a big inter-stage gap ship `previewBreaks`
// (indices where a new run starts); split there so Leaflet draws
// disconnected segments instead of a line across the transfer.
const breaks = hike.previewBreaks;
let coords: [number, number][] | [number, number][][] = latLngs;
if (breaks && breaks.length > 0) {
const segs: [number, number][][] = [];
let start = 0;
for (const brk of breaks) {
if (brk > start) segs.push(latLngs.slice(start, brk));
start = brk;
}
segs.push(latLngs.slice(start));
coords = segs.filter((s) => s.length >= 2);
}
const poly = L.polyline(coords, {
color,
weight: 4,
opacity: 0.9,
interactive: true
}).addTo(layer);
poly.bindTooltip(
`<strong>${hike.title}</strong><br>` +
`${hike.distanceKm.toFixed(1)} km · ↑${hike.elevationGainM} m · SAC ${hike.difficulty}`,
{ sticky: true, direction: 'top', opacity: 0.95, className: 'hike-overview-tooltip' }
);
poly.on('mouseover', () => {
poly.setStyle({ weight: 7, opacity: 1 });
poly.bringToFront();
});
poly.on('mouseout', () => {
poly.setStyle({ weight: 4, opacity: 0.9 });
});
poly.on('click', () => {
goto(resolve('/hikes/[slug]', { slug: hike.slug }));
});
for (const [lat, lng] of latLngs) {
b.extend([lat, lng]);
}
}
if (b.isValid()) {
initialBounds = b;
recenterMap = () => {
if (!initialBounds) return;
map.flyToBounds(initialBounds, {
padding: [32, 32],
maxZoom: 13,
duration: 0.6,
easeLinearity: 0.25
});
};
return true;
}
initialBounds = null;
recenterMap = null;
return false;
}
// Initial paint — no animated fit when the caller handed us a
// pre-rendered hero pose (the tile-load handover handles the
// fly-to), otherwise fit straight to the union bounds.
if (renderPolylines() && (!initialCenter || typeof initialZoom !== 'number') && initialBounds) {
map.fitBounds(initialBounds, { padding: [32, 32], maxZoom: 13 });
}
// User location (opt-in). Same Tauri-first / Web-Geolocation-fallback
// pattern as HikeMap so the toggle behaves identically across the app.
let userMarker: ReturnType<typeof L.circleMarker> | null = null;
let userAccuracyCircle: ReturnType<typeof L.circle> | null = null;
let userCleanup: (() => void) | undefined;
async function attachUserLocation() {
if (!enableUserLocation) return;
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
const handlePos = (lat: number, lng: number, accuracy: number) => {
if (!userMarker) {
userMarker = L.circleMarker([lat, lng], {
radius: 7,
fillColor: '#5e81ac',
fillOpacity: 1,
color: '#fff',
weight: 2
}).addTo(map);
userAccuracyCircle = L.circle([lat, lng], {
radius: accuracy,
color: '#5e81ac',
fillColor: '#5e81ac',
fillOpacity: 0.1,
weight: 1
}).addTo(map);
} else {
userMarker.setLatLng([lat, lng]);
userAccuracyCircle?.setLatLng([lat, lng]);
userAccuracyCircle?.setRadius(accuracy);
}
};
if (isTauri) {
try {
const geo = await import('@tauri-apps/plugin-geolocation');
const watchId = await geo.watchPosition(
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10_000 },
(pos) => {
if (pos?.coords)
handlePos(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy ?? 30);
}
);
userCleanup = () => geo.clearWatch(watchId).catch(() => {});
} catch {
/* Tauri plugin unavailable — fall through to web API */
}
}
if (!userCleanup && 'geolocation' in navigator) {
const id = navigator.geolocation.watchPosition(
(pos) => handlePos(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy),
() => {},
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10_000 }
);
userCleanup = () => navigator.geolocation.clearWatch(id);
}
}
attachUserLocation();
// React to control toggles outside the attachment.
const stopReactRoot = $effect.root(() => {
// Re-render polylines whenever the `hikes` prop changes
// (filter bar toggles, tag deep-link). The first $effect
// run fires immediately and would re-do the initial paint
// for no UX gain — skip it via a tick counter.
let rerunTick = 0;
$effect(() => {
void hikes;
if (rerunTick++ === 0) return;
if (renderPolylines() && initialBounds) {
// Smooth re-fit so the user sees the camera glide
// toward whichever subset is now on display.
map.flyToBounds(initialBounds, {
padding: [32, 32],
maxZoom: 13,
duration: 0.6,
easeLinearity: 0.25
});
}
});
$effect(() => {
if (baseLayer === currentBase) return;
tileLayers[currentBase].remove();
tileLayers[baseLayer].addTo(map);
const newMax = LAYER_DEFS[baseLayer].maxZoom;
map.setMaxZoom(newMax);
if (map.getZoom() > newMax) map.setZoom(newMax);
currentBase = baseLayer;
});
$effect(() => {
if (!enableUserLocation && userCleanup) {
userCleanup();
userCleanup = undefined;
if (userMarker) userMarker.remove();
if (userAccuracyCircle) userAccuracyCircle.remove();
userMarker = null;
userAccuracyCircle = null;
} else if (enableUserLocation && !userCleanup) {
attachUserLocation();
}
});
});
cleanup = () => {
userCleanup?.();
stopReactRoot();
recenterMap = null;
map.remove();
};
})();
return () => {
cancelled = true;
cleanup?.();
};
};
</script>
<svelte:head>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="preconnect" href="https://maps.bocken.org" crossorigin="anonymous" />
</svelte:head>
<div class="map-wrap">
<div class="overview-map" {@attach mapAttachment} aria-label="Übersichtskarte aller Wanderungen"></div>
<div class="map-controls">
<div class="layer-menu" class:open={layerMenuOpen}>
<button
type="button"
class="round-btn"
aria-label="Kartenebene wählen"
aria-haspopup="menu"
aria-expanded={layerMenuOpen}
onclick={(e) => {
e.stopPropagation();
layerMenuOpen = !layerMenuOpen;
}}
>
<Layers size={20} strokeWidth={2} aria-hidden="true" />
</button>
{#if layerMenuOpen}
<div class="layer-popover" role="menu">
{#each Object.entries(LAYER_DEFS) as [key, def] (key)}
{@const Icon = def.icon}
<button
type="button"
role="menuitemradio"
aria-checked={baseLayer === key}
class:active={baseLayer === key}
onclick={() => {
baseLayer = key as BaseLayer;
layerMenuOpen = false;
}}
>
<Icon size={14} strokeWidth={1.75} aria-hidden="true" />
{def.label}
</button>
{/each}
</div>
{/if}
</div>
{#if recenterMap}
<button
type="button"
class="round-btn"
aria-label="Auf alle Touren zurückzentrieren"
title="Karte auf alle Touren zurückzentrieren"
onclick={() => recenterMap?.()}
>
<Maximize2 size={18} strokeWidth={2} aria-hidden="true" />
</button>
{/if}
<button
type="button"
class="round-btn"
class:active={enableUserLocation}
aria-pressed={enableUserLocation}
title={enableUserLocation
? 'Eigenen Standort verbergen'
: 'Eigenen Standort anzeigen — wird lokal berechnet, nicht an Dritte gesendet'}
aria-label={enableUserLocation ? 'Eigenen Standort verbergen' : 'Eigenen Standort anzeigen'}
onclick={toggleLocation}
>
{#if enableUserLocation}
<Locate size={20} strokeWidth={2} aria-hidden="true" />
{:else}
<LocateOff size={20} strokeWidth={2} aria-hidden="true" />
{/if}
</button>
</div>
{#if locationError}
<p class="gps-error" role="status">{locationError}</p>
{/if}
</div>
<style>
.map-wrap {
position: relative;
width: 100%;
}
.overview-map {
width: 100%;
height: clamp(320px, 50vh, 520px);
background: var(--color-bg-elevated);
}
/* Tooltip lives at body level, so it has to be global. */
:global(.hike-overview-tooltip) {
font: inherit;
font-size: 0.8rem;
line-height: 1.35;
padding: 0.4rem 0.6rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-primary);
box-shadow: var(--shadow-md);
}
:global(.hike-overview-tooltip strong) {
display: block;
margin-bottom: 0.1rem;
color: var(--color-text-primary);
}
:global(.leaflet-interactive) {
cursor: pointer;
}
/* Bottom-right stack of round controls. Mirrors HikeMap.svelte exactly so
* users get the same controls and visual language as the detail page. */
.map-controls {
position: absolute;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
z-index: 500;
}
.round-btn {
display: grid;
place-items: center;
width: 44px;
height: 44px;
background: var(--color-surface);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: 50%;
box-shadow: var(--shadow-md);
cursor: pointer;
transition:
color var(--transition-fast),
background var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.round-btn:hover {
color: var(--color-primary);
transform: scale(1.05);
box-shadow: var(--shadow-hover);
}
.round-btn.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
.round-btn.active:hover {
color: var(--color-text-on-primary);
}
.layer-menu {
position: relative;
}
.layer-popover {
position: absolute;
right: calc(100% + 0.5rem);
bottom: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.3rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 9.5rem;
white-space: nowrap;
}
.layer-popover button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.7rem;
border: 0;
background: transparent;
color: var(--color-text-primary);
font: inherit;
font-size: 0.85rem;
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
transition: background var(--transition-fast), color var(--transition-fast);
}
.layer-popover button :global(svg) {
color: var(--color-text-tertiary);
flex: 0 0 auto;
}
.layer-popover button:hover {
background: var(--color-bg-elevated);
}
.layer-popover button.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.layer-popover button.active :global(svg) {
color: var(--color-text-on-primary);
}
/* GPS-permission error toast. Three 44 px buttons + two 0.5 rem gaps =
* ~148 px stack plus 1 rem inset; anchor the toast above that. */
.gps-error {
position: absolute;
bottom: 11rem;
right: 1rem;
max-width: 18rem;
margin: 0;
padding: 0.5rem 0.75rem;
background: var(--color-surface);
color: var(--red);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 0.78rem;
z-index: 500;
}
</style>
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
<script lang="ts">
// Dual-thumb range slider: one track, two handles (lower + upper bound).
// Custom pointer/keyboard implementation rather than two overlaid
// <input type=range> elements — the latter lock up when both thumbs
// coincide at an edge. Here a drag that crosses the other thumb hands off
// to it, so the range is always adjustable.
interface Props {
label: string;
/** Track extent (data floor / ceiling). */
min: number;
max: number;
step?: number;
/** Current lower bound. Bindable. */
low: number;
/** Current upper bound. Bindable. */
high: number;
/** Renders a value for the readout + aria-valuetext. */
format?: (v: number) => string;
}
let {
label,
min,
max,
step = 1,
low = $bindable(),
high = $bindable(),
format = (v) => String(v)
}: Props = $props();
let trackEl = $state<HTMLElement>();
let lowThumb = $state<HTMLElement>();
let highThumb = $state<HTMLElement>();
let dragging = $state<null | 'low' | 'high'>(null);
const span = $derived(Math.max(1, max - min));
// Clamp for display so an out-of-range initial value (e.g. ±Infinity
// before the data defaults land) still paints a sane thumb position.
const lowPct = $derived(((Math.min(Math.max(low, min), max) - min) / span) * 100);
const highPct = $derived(((Math.min(Math.max(high, min), max) - min) / span) * 100);
function snap(v: number) {
return Math.round(v / step) * step;
}
function setLow(v: number) {
low = Math.min(Math.max(snap(v), min), high);
}
function setHigh(v: number) {
high = Math.max(Math.min(snap(v), max), low);
}
function valueFromClientX(clientX: number) {
if (!trackEl) return min;
const r = trackEl.getBoundingClientRect();
const ratio = r.width > 0 ? (clientX - r.left) / r.width : 0;
return min + Math.min(Math.max(ratio, 0), 1) * (max - min);
}
// Move the active thumb; if it crosses the other one, hand the drag over so
// dragging stays continuous instead of stalling at the collision point.
function update(which: 'low' | 'high', raw: number) {
const v = Math.min(Math.max(snap(raw), min), max);
if (which === 'low') {
if (v > high) {
dragging = 'high';
highThumb?.focus();
setHigh(v);
} else setLow(v);
} else {
if (v < low) {
dragging = 'low';
lowThumb?.focus();
setLow(v);
} else setHigh(v);
}
}
function onTrackPointerDown(e: PointerEvent) {
e.preventDefault();
const v = valueFromClientX(e.clientX);
const which: 'low' | 'high' = Math.abs(v - low) <= Math.abs(v - high) ? 'low' : 'high';
dragging = which;
(which === 'low' ? lowThumb : highThumb)?.focus();
trackEl?.setPointerCapture(e.pointerId);
update(which, v);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
update(dragging, valueFromClientX(e.clientX));
}
function onPointerUp() {
dragging = null;
}
function onThumbKey(e: KeyboardEvent, which: 'low' | 'high') {
const big = step * 10;
let delta = 0;
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
delta = step;
break;
case 'ArrowLeft':
case 'ArrowDown':
delta = -step;
break;
case 'PageUp':
delta = big;
break;
case 'PageDown':
delta = -big;
break;
case 'Home':
e.preventDefault();
if (which === 'low') setLow(min);
else setHigh(low);
return;
case 'End':
e.preventDefault();
if (which === 'low') setLow(high);
else setHigh(max);
return;
default:
return;
}
e.preventDefault();
if (which === 'low') setLow(low + delta);
else setHigh(high + delta);
}
</script>
<div class="rs">
<div class="rs-head">
<span class="rs-label">{label}</span>
<span class="rs-value">{format(low)} {format(high)}</span>
</div>
<div
class="rs-track"
role="group"
aria-label={label}
bind:this={trackEl}
onpointerdown={onTrackPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<div class="rs-rail"></div>
<div class="rs-fill" style="left: {lowPct}%; right: {100 - highPct}%;"></div>
<button
type="button"
class="rs-thumb"
class:active={dragging === 'low'}
bind:this={lowThumb}
style="left: {lowPct}%"
role="slider"
tabindex="0"
aria-label="{label} Minimum"
aria-valuemin={min}
aria-valuemax={high}
aria-valuenow={low}
aria-valuetext={format(low)}
onkeydown={(e) => onThumbKey(e, 'low')}
></button>
<button
type="button"
class="rs-thumb"
class:active={dragging === 'high'}
bind:this={highThumb}
style="left: {highPct}%"
role="slider"
tabindex="0"
aria-label="{label} Maximum"
aria-valuemin={low}
aria-valuemax={max}
aria-valuenow={high}
aria-valuetext={format(high)}
onkeydown={(e) => onThumbKey(e, 'high')}
></button>
</div>
</div>
<style>
.rs {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.rs-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
.rs-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-tertiary);
}
.rs-value {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
.rs-track {
position: relative;
height: 1.25rem;
touch-action: none;
cursor: pointer;
}
.rs-rail,
.rs-fill {
position: absolute;
top: 50%;
height: 0.3rem;
transform: translateY(-50%);
border-radius: var(--radius-pill);
}
.rs-rail {
left: 0;
right: 0;
background: var(--color-bg-elevated);
}
.rs-fill {
background: var(--color-primary);
}
.rs-thumb {
position: absolute;
top: 50%;
width: 1.05rem;
height: 1.05rem;
margin: 0;
padding: 0;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--color-surface);
border: 2px solid var(--color-primary);
box-shadow: var(--shadow-sm);
cursor: grab;
appearance: none;
transition: scale var(--transition-fast);
}
.rs-thumb:hover {
scale: 1.1;
}
.rs-thumb:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.rs-thumb.active {
cursor: grabbing;
scale: 1.15;
}
</style>
@@ -0,0 +1,74 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte';
interface Props {
enabled?: boolean;
}
let { enabled = $bindable(false) }: Props = $props();
const STORAGE_KEY = 'hikes:gpsEnabled';
let permissionError = $state<string | null>(null);
// Initialise from localStorage on mount (browser only).
$effect(() => {
if (typeof window === 'undefined') return;
const saved = window.localStorage.getItem(STORAGE_KEY);
if (saved === '1') enabled = true;
});
$effect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0');
});
function onChange() {
if (!enabled) {
permissionError = null;
return;
}
// Light pre-flight: confirm the API exists. The actual permission grant
// happens lazily inside HikeMap so users see the marker appear immediately
// once they accept.
if (typeof window === 'undefined') return;
const hasTauri = '__TAURI_INTERNALS__' in window;
const hasWebGeo = 'geolocation' in navigator;
if (!hasTauri && !hasWebGeo) {
enabled = false;
permissionError = 'Geolocation steht in diesem Browser nicht zur Verfügung.';
}
}
</script>
<div class="user-loc">
<Toggle bind:checked={enabled} label="Eigenen Standort auf der Karte anzeigen" onchange={onChange} />
<p class="hint">
Dein Standort wird auf deinem Gerät berechnet und nicht an Dritte gesendet.
</p>
{#if permissionError}
<p class="err">{permissionError}</p>
{/if}
</div>
<style>
.user-loc {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.hint {
margin: 0.4rem 0 0;
font-size: 0.8rem;
color: var(--color-text-tertiary);
}
.err {
margin: 0.4rem 0 0;
font-size: 0.85rem;
color: var(--red);
}
</style>
@@ -0,0 +1,42 @@
/**
* Shared focus state for a hike detail page's photo strip + map.
*
* Writing to `focused.index` from the strip (source='strip') makes the map fly
* to that photo and pulse a focus ring; writing from the map (source='map')
* makes the strip scroll the matching card into view and highlight it. Each
* side ignores its own writes via the `source` field so the two never feed
* back into each other.
*
* Indexes are positions in the visibility-filtered `ImagePoint[]` that both
* components share the page filters once and hands the same array down.
*/
/**
* Sources of focus-store writes:
* - `'strip'`: the user clicked a thumbnail or used a chevron / arrow key.
* Full sync: map flies to the marker, strip centres the card.
* - `'map'`: the user clicked a map marker. Strip scrolls + highlights,
* but the map doesn't fly to itself.
* - `'map-hover'`: the user is hovering a map marker. Strip skips scroll
* (would jerk across dense clusters), and the map skips its own flyTo +
* focus ring (the user is already looking at it).
* - `'inline'`: an inline `<HikeImage>` scrolled into the viewport's middle
* band. Full sync: map flies to the marker, strip centres the card. This
* is the desktop scrollytelling driver.
*/
export type FocusSource = 'map' | 'map-hover' | 'strip' | 'inline' | null;
export const focused = $state<{ index: number | null; source: FocusSource }>({
index: null,
source: null
});
export function setFocused(index: number | null, source: FocusSource): void {
focused.index = index;
focused.source = source;
}
export function clearFocused(): void {
focused.index = null;
focused.source = null;
}
@@ -0,0 +1,49 @@
/**
* Provides the hike detail page's ImagePoints arrays to descendants
* specifically, to inline `<HikeImage>` components used inside `.svx`
* content. The page sets the context; HikeImage reads it.
*
* Two arrays are exposed because they serve different needs:
*
* - `images` is the full chronological list (including private images).
* `<HikeImage idx={N} />` indexes into this list, so the author's
* indices stay stable regardless of the viewer's login state.
*
* - `visibleImages` is the same list with private entries filtered out
* for the current viewer. The strip, map, and stage all operate against
* it, and the focus store's `index` field is a position in this array.
* `HikeImage` translates its own idx position-in-visibleImages so the
* focus sync works.
*/
import { getContext, setContext } from 'svelte';
import type { HikeTrackPoint, ImagePoint, NamedHikeImage } from '$types/hikes';
const KEY = Symbol('hike-context');
interface HikeContext {
readonly images: ImagePoint[];
readonly visibleImages: ImagePoint[];
/** GPX track points null until the JSON fetch resolves. Used by
* inline `<HikeImage>` to compute the nearest-track-index for the
* scroll-progress pin on the map. */
readonly track: HikeTrackPoint[] | null;
/** Images addressable by source filename for `<HikeImage src="">`,
* keyed by source basename. */
readonly imagesByName: Record<string, NamedHikeImage>;
/** Whether the current viewer may see private images. Path-mode
* `<HikeImage src>` hides private images when this is false. */
readonly showPrivate: boolean;
}
export function setHikeContext(ctx: () => HikeContext): void {
setContext(KEY, ctx);
}
export function getHikeContext(): () => HikeContext {
const ctx = getContext<() => HikeContext>(KEY);
if (!ctx) {
throw new Error('HikeImage used outside a hike detail page (no context found).');
}
return ctx;
}
@@ -0,0 +1,28 @@
/**
* Shared cursor state for a hike detail page.
*
* The map and the elevation chart each push into `hover.index` when the
* pointer moves over them; both observe the rune via `$effect` to draw the
* corresponding marker on their own side. A single shared rune avoids the
* mapchart hover-loop bookkeeping that prop wiring would require.
*
* `source` records which side wrote the last update so the receiver can skip
* redrawing on its own write and prevent feedback loops.
*/
export type HoverSource = 'map' | 'chart' | 'image' | 'scroll' | null;
export const hover = $state<{ index: number | null; source: HoverSource }>({
index: null,
source: null
});
export function setHover(index: number | null, source: HoverSource): void {
hover.index = index;
hover.source = source;
}
export function clearHover(): void {
hover.index = null;
hover.source = null;
}
@@ -0,0 +1,628 @@
<script lang="ts">
import type { Attachment } from 'svelte/attachments';
import {
builder,
mapView,
nextWaypointId,
scheduleSave
} from './builderStore.svelte';
import { SAC_TRAIL_COLOR } from '$lib/data/sacColors';
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
import MapIcon from '@lucide/svelte/icons/map';
import Satellite from '@lucide/svelte/icons/satellite';
import Layers from '@lucide/svelte/icons/layers';
// Single-point Swisstopo elevation lookups are intentionally NOT used —
// they returned 0 against WGS-84 inputs in practice, and image waypoints
// don't need per-point altitudes anyway. Waypoint altitudes flow from
// the routed-segment elevations that snap-to-route populates on the
// route polyline; `assembleTrackPoints` falls back to those when the
// waypoint itself has no `altitude`.
interface Props {
/** When set, the next map click writes the clicked lat/lng into the
* matching unplaced waypoint (instead of creating a new one). */
pendingPlacementId?: string | null;
onPlacementComplete?: () => void;
onPlacementCancel?: () => void;
}
const { pendingPlacementId = null, onPlacementComplete, onPlacementCancel }: Props = $props();
const pendingWaypoint = $derived(
pendingPlacementId ? builder.waypoints.find((w) => w.id === pendingPlacementId) ?? null : null
);
// Schematic ↔ satellite base layer (satellite helps placing waypoints on
// trails/landmarks, esp. off the marked path). Same bottom-right layer
// popover as the detail / overview maps.
type BaseLayer = 'schematic' | 'aerial';
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof MapIcon }> = {
schematic: { label: 'Karte', icon: MapIcon },
aerial: { label: 'Luftbild', icon: Satellite }
};
let baseLayer = $state<BaseLayer>('schematic');
let layerMenuOpen = $state(false);
// Close the layer popover on outside click (the opening click stops
// propagation so this never sees it).
$effect(() => {
if (!layerMenuOpen) return;
function onAway(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target && !target.closest('.layer-menu')) layerMenuOpen = false;
}
window.addEventListener('click', onAway);
return () => window.removeEventListener('click', onAway);
});
// Default view: Switzerland-wide.
const DEFAULT_CENTER: [number, number] = [46.8, 8.3];
const DEFAULT_ZOOM = 8;
const TRACK_COLOR = SAC_TRAIL_COLOR.T2;
const ACCENT_COLOR = '#2965c8'; // SAC T4 blue — used for the focused-marker accent ring
function escapeAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
// Pin geometry:
// - Solo pin: 28 wide × 36 tall, head r=10 at (14,14), tip at (14,36).
// - Image pin: 44 wide × 52 tall, head r=15 (clip) inside r=18 (frame)
// at (22,22), tip at (22,52).
// Both anchor at the tip so `iconAnchor = [width/2, height]`.
function makePinIcon(num: number, opts: { active: boolean }) {
const ring = opts.active ? ACCENT_COLOR : 'white';
const ringWidth = opts.active ? 3 : 2;
const html = `
<svg viewBox="0 0 28 36" width="28" height="36" class="rb-pin solo${opts.active ? ' is-active' : ''}" aria-hidden="true">
<path d="M14 36 L5.1 18.5 A10 10 0 1 1 22.9 18.5 Z"
fill="${TRACK_COLOR}" stroke="${ring}" stroke-width="${ringWidth}" stroke-linejoin="round" />
<text x="14" y="17.6" text-anchor="middle" font-size="11" font-weight="700"
fill="white" font-family="ui-sans-serif,system-ui,Helvetica,Arial,sans-serif">${num}</text>
</svg>`;
return { html, size: [28, 36] as [number, number], anchor: [14, 36] as [number, number] };
}
function makeImagePinIcon(num: number, thumb: string, opts: { active: boolean }) {
const safeThumb = escapeAttr(thumb);
const ring = opts.active ? ACCENT_COLOR : TRACK_COLOR;
const ringWidth = opts.active ? 3 : 2.5;
const clipId = `rb-pin-head-${Math.random().toString(36).slice(2, 8)}`;
const html = `
<svg viewBox="0 0 44 52" width="44" height="52" class="rb-pin image${opts.active ? ' is-active' : ''}" aria-hidden="true">
<defs>
<clipPath id="${clipId}"><circle cx="22" cy="22" r="15" /></clipPath>
</defs>
<path d="M22 52 L7.6 32.8 A18 18 0 1 1 36.4 32.8 Z"
fill="white" stroke="${ring}" stroke-width="${ringWidth}" stroke-linejoin="round" />
<image href="${safeThumb}" x="7" y="7" width="30" height="30"
clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice" />
<g transform="translate(34 9)">
<circle r="7.5" fill="${ring}" stroke="white" stroke-width="1.5" />
<text y="3" text-anchor="middle" font-size="9" font-weight="700" fill="white"
font-family="ui-sans-serif,system-ui,Helvetica,Arial,sans-serif">${num}</text>
</g>
</svg>`;
return { html, size: [44, 52] as [number, number], anchor: [22, 52] as [number, number] };
}
const editAttachment: Attachment<HTMLElement> = (node) => {
let cancelled = false;
let cleanup: (() => void) | undefined;
(async () => {
const L = await import('leaflet');
if (cancelled || !node.isConnected) return;
const map = L.map(node, {
// On-map attribution removed for a cleaner frame; the required
// swisstopo credit is shown in the page footer instead.
attributionControl: false,
zoomControl: true,
preferCanvas: false
}).setView(DEFAULT_CENTER, DEFAULT_ZOOM);
const tileLayers = {
schematic: L.tileLayer(TILE_URL.karte, {
maxZoom: 19,
minZoom: 7,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
}),
aerial: L.tileLayer(TILE_URL.luftbild, {
maxZoom: 19,
minZoom: 7,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
})
};
tileLayers.schematic.addTo(map);
let currentBase: 'schematic' | 'aerial' = 'schematic';
const markerLayer = L.layerGroup().addTo(map);
const lineLayer = L.layerGroup().addTo(map);
// Route polylines render on a canvas with a hit-test `tolerance`, so a
// click *near* the (thin) line still inserts a waypoint into the route
// — same trick as the /hikes overview map's broadened hover.
const routeRenderer = L.canvas({ tolerance: 12 });
// Map of waypointId → marker, kept in sync by render(). Used by the
// focus effect so it can pan/zoom + style the marker for `mapView.focusId`
// without forcing a full re-render of every marker.
const markerByWp = new Map<string, ReturnType<typeof L.marker>>();
function buildIcon(num: number, wp: { thumbnail?: string }, active: boolean) {
const spec = wp.thumbnail
? makeImagePinIcon(num, wp.thumbnail, { active })
: makePinIcon(num, { active });
return L.divIcon({
className: 'rb-waypoint',
html: spec.html,
iconSize: spec.size,
iconAnchor: spec.anchor
});
}
function insertWaypointAfterFullIdx(fullAfterIdx: number, lat: number, lng: number) {
const id = nextWaypointId();
const fixedLat = Number(lat.toFixed(6));
const fixedLng = Number(lng.toFixed(6));
builder.waypoints.splice(fullAfterIdx + 1, 0, {
id,
lat: fixedLat,
lng: fixedLng,
timestamp: null
});
scheduleSave();
}
function attachSegmentClick(
poly: ReturnType<typeof L.polyline>,
fullAfterIdx: number
) {
poly.on('click', (ev: { latlng: { lat: number; lng: number }; originalEvent?: MouseEvent } & object) => {
L.DomEvent.stopPropagation(ev as Parameters<typeof L.DomEvent.stopPropagation>[0]);
insertWaypointAfterFullIdx(fullAfterIdx, ev.latlng.lat, ev.latlng.lng);
});
}
function render() {
markerLayer.clearLayers();
lineLayer.clearLayers();
markerByWp.clear();
// Markers per waypoint. Skip unplaced ones — they don't have a
// usable lat/lng and live only in the waypoint table.
const placedIndices: number[] = [];
builder.waypoints.forEach((w, idx) => {
if (w.unplaced) return;
placedIndices.push(idx);
});
const focusId = mapView.focusId;
placedIndices.forEach((idx, displayPos) => {
const w = builder.waypoints[idx];
const seqNum = displayPos + 1;
const marker = L.marker([w.lat, w.lng], {
icon: buildIcon(seqNum, w, w.id === focusId),
draggable: true,
// Lift the focused marker above its neighbours so its accent
// ring isn't covered by an adjacent unfocused pin.
zIndexOffset: w.id === focusId ? 1000 : 0
}).addTo(markerLayer);
marker.on('dragend', () => {
const p = marker.getLatLng();
const wp = builder.waypoints[idx];
wp.lat = Number(p.lat.toFixed(6));
wp.lng = Number(p.lng.toFixed(6));
wp.altitude = undefined;
scheduleSave();
render();
});
marker.on('contextmenu', () => {
builder.waypoints.splice(idx, 1);
scheduleSave();
render();
});
marker.on('click', () => {
mapView.focusId = w.id;
mapView.focusTick++;
});
markerByWp.set(w.id, marker);
});
// Lines: per-pair so each can carry a segIdx for inline insertion.
// Snapped + linear segments share the same visual styling — there's
// no need to call out the difference, the user picked the mode.
// SAC white-red-white red — matches /hikes overview + detail-page
// trail colour so the live preview reads as the final published track.
if (builder.routedSegments.length > 0) {
builder.routedSegments.forEach((seg, segIdx) => {
const latLngs = seg.map((p) => [p[1], p[0]] as [number, number]);
const poly = L.polyline(latLngs, {
color: TRACK_COLOR,
weight: 4,
opacity: 0.9,
renderer: routeRenderer
}).addTo(lineLayer);
// Routed segments index aligns with placed-only pairs; map back
// to the full waypoint-array index so inline insertion still
// places the new waypoint correctly relative to unplaced ones.
const fullAfterIdx = placedIndices[segIdx];
attachSegmentClick(poly, fullAfterIdx);
});
}
}
function fitToTrack() {
const points: [number, number][] = [];
for (const w of builder.waypoints) {
if (w.unplaced) continue;
points.push([w.lat, w.lng]);
}
for (const seg of builder.routedSegments) {
for (const p of seg) points.push([p[1], p[0]]);
}
if (points.length === 0) return;
if (points.length === 1) {
map.setView(points[0], 13);
return;
}
map.fitBounds(L.latLngBounds(points), { padding: [40, 40] });
}
function focusOnWaypoint(id: string | null) {
if (!id) return;
const wp = builder.waypoints.find((w) => w.id === id);
if (!wp || wp.unplaced) return;
// Zoom in but don't over-zoom — 16 reads as "this trail junction"
// without losing surrounding context. flyTo gives smooth motion.
const targetZoom = Math.max(map.getZoom(), 16);
map.flyTo([wp.lat, wp.lng], targetZoom, { duration: 0.6 });
}
// React to store changes.
const stopRoot = $effect.root(() => {
// Base-layer switch (schematic ↔ satellite).
$effect(() => {
if (baseLayer === currentBase) return;
tileLayers[currentBase].remove();
tileLayers[baseLayer].addTo(map);
currentBase = baseLayer;
});
$effect(() => {
// Touch each reactive field so we re-render on any mutation,
// including focus changes (so the active marker re-styles).
builder.waypoints.length;
for (const w of builder.waypoints) {
w.lat; w.lng; w.thumbnail;
}
builder.routedSegments.length;
mapView.focusId;
render();
});
// External fit-bounds requests (image drops, GPX imports).
// The map's own init-time auto-fit covers first-load; this
// effect handles every subsequent batch insertion.
let lastFitTick = mapView.fitTick;
$effect(() => {
const tick = mapView.fitTick;
if (tick === lastFitTick) return;
lastFitTick = tick;
fitToTrack();
});
// Focus requests (table row "fokussieren", prev/next nav bar).
// Tick is bumped on every request even if the id stays the same
// so repeated clicks re-center even if the user panned away.
let lastFocusTick = mapView.focusTick;
$effect(() => {
const tick = mapView.focusTick;
if (tick === lastFocusTick) return;
lastFocusTick = tick;
focusOnWaypoint(mapView.focusId);
});
});
// Click on blank map. In normal mode, append a new waypoint at the end.
// When a placement is pending (the user clicked "Auf Karte platzieren"
// on an unplaced image in the waypoint table), instead drop the
// clicked lat/lng into that existing waypoint — preserving its
// chronological position in the table.
map.on('click', async (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
if (e.originalEvent.shiftKey) return;
const lat = Number(e.latlng.lat.toFixed(6));
const lng = Number(e.latlng.lng.toFixed(6));
if (pendingPlacementId) {
const wp = builder.waypoints.find((w) => w.id === pendingPlacementId);
if (!wp) return;
wp.lat = lat;
wp.lng = lng;
wp.unplaced = false;
scheduleSave();
onPlacementComplete?.();
return;
}
const id = nextWaypointId();
builder.waypoints.push({ id, lat, lng, timestamp: null });
scheduleSave();
});
// Auto-fit once when waypoints first exist.
if (builder.waypoints.length >= 2) {
const bounds = L.latLngBounds(builder.waypoints.map((w) => [w.lat, w.lng]));
map.fitBounds(bounds, { padding: [40, 40] });
} else if (builder.waypoints.length === 1) {
const w = builder.waypoints[0];
map.setView([w.lat, w.lng], 13);
}
cleanup = () => {
stopRoot();
map.remove();
};
})();
return () => {
cancelled = true;
cleanup?.();
};
};
</script>
<div class="edit-map-wrap" class:placement-mode={!!pendingWaypoint}>
<div class="edit-map" {@attach editAttachment}></div>
<div class="map-controls">
<div class="layer-menu" class:open={layerMenuOpen}>
<button
type="button"
class="round-btn"
aria-label="Kartenebene wählen"
aria-haspopup="menu"
aria-expanded={layerMenuOpen}
onclick={(e) => {
e.stopPropagation();
layerMenuOpen = !layerMenuOpen;
}}
>
<Layers size={20} strokeWidth={2} aria-hidden="true" />
</button>
{#if layerMenuOpen}
<div class="layer-popover" role="menu">
{#each Object.entries(LAYER_DEFS) as [key, def] (key)}
{@const Icon = def.icon}
<button
type="button"
role="menuitemradio"
aria-checked={baseLayer === key}
class:active={baseLayer === key}
onclick={() => {
baseLayer = key as BaseLayer;
layerMenuOpen = false;
}}
>
<Icon size={14} strokeWidth={1.75} aria-hidden="true" />
{def.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
{#if pendingWaypoint}
<div class="placement-banner" role="status">
<span>Klicke auf die Karte, um <strong>das Bild</strong> zu platzieren.</span>
<button type="button" onclick={() => onPlacementCancel?.()}>Abbrechen</button>
</div>
{/if}
</div>
<style>
.edit-map-wrap {
position: relative;
}
.edit-map {
width: 100%;
height: 640px;
border-radius: var(--radius-card);
overflow: hidden;
box-shadow: var(--shadow-md);
background: var(--color-bg-elevated);
}
@media (max-width: 900px) {
.edit-map {
height: 520px;
}
}
/* Default cursor is a pointing hand — a click adds a waypoint (on blank map
* or, with the canvas tolerance, near a route). Leaflet turns the
* `.edit-map` div itself into the container, so the grab cursor lives on
* this element (not a descendant) — target it directly. */
.edit-map:global(.leaflet-container) {
cursor: pointer;
}
/* Waypoint pins are the only draggable thing — show the drag hand on them. */
.edit-map :global(.rb-waypoint) {
cursor: grab;
}
.edit-map :global(.rb-waypoint:active) {
cursor: grabbing;
}
/* Placement mode (dropping an unplaced image) keeps the crosshair. */
.edit-map-wrap.placement-mode :global(.leaflet-container) {
cursor: crosshair;
}
/* Bottom-right round controls + layer popover — same language as the
* detail / overview maps. */
.map-controls {
position: absolute;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
z-index: 500;
}
.round-btn {
display: grid;
place-items: center;
width: 44px;
height: 44px;
background: var(--color-surface);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: 50%;
box-shadow: var(--shadow-md);
cursor: pointer;
transition:
color var(--transition-fast),
background var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.round-btn:hover {
color: var(--color-primary);
transform: scale(1.05);
box-shadow: var(--shadow-hover);
}
.layer-menu {
position: relative;
}
.layer-popover {
position: absolute;
right: calc(100% + 0.5rem);
bottom: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.3rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 9.5rem;
white-space: nowrap;
}
.layer-popover button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.7rem;
border: 0;
background: transparent;
color: var(--color-text-primary);
font: inherit;
font-size: 0.85rem;
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
transition: background var(--transition-fast), color var(--transition-fast);
}
.layer-popover button :global(svg) {
color: var(--color-text-tertiary);
flex: 0 0 auto;
}
.layer-popover button:hover {
background: var(--color-bg-elevated);
}
.layer-popover button.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.layer-popover button.active :global(svg) {
color: var(--color-text-on-primary);
}
.placement-banner {
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
gap: 0.75rem;
align-items: center;
padding: 0.5rem 0.9rem;
background: var(--color-surface);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-md);
font-size: 0.85rem;
z-index: 500;
max-width: calc(100% - 2rem);
}
.placement-banner button {
appearance: none;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font: inherit;
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
border-radius: var(--radius-pill);
cursor: pointer;
}
/* Leaflet wraps each marker in `.leaflet-marker-icon` with its own
* absolute positioning. We just neutralise its default frame/background
* so the SVG pin shows through cleanly. */
:global(.rb-waypoint) {
background: transparent !important;
border: 0 !important;
}
:global(.rb-pin) {
display: block;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
transition: filter 200ms ease, transform 200ms ease;
transform-origin: 50% 100%;
}
:global(.rb-waypoint:hover .rb-pin) {
transform: scale(1.08);
}
:global(.rb-pin.is-active) {
filter: drop-shadow(0 0 6px color-mix(in oklab, #2965c8 70%, transparent))
drop-shadow(0 2px 3px rgba(0, 0, 0, 0.4));
animation: rb-pin-bounce 0.55s ease-out;
}
@keyframes rb-pin-bounce {
0% { transform: scale(0.85) translateY(-4px); }
60% { transform: scale(1.12); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
:global(.rb-pin.is-active) {
animation: none;
}
}
</style>
@@ -0,0 +1,538 @@
<script lang="ts">
import {
builder,
insertWaypointChronologically,
nextWaypointId,
requestFitBounds,
scheduleSave,
type Waypoint
} from './builderStore.svelte';
// `untrack` keeps the in-loop `builder.waypoints.find(...)` from
// registering as a dep on a non-reactive call site, avoiding effect
// loops when we patch the matched waypoint's `thumbnail`.
import { untrack } from 'svelte';
import { generateImageHashClient } from '$lib/imageHashClient';
import { readThumbnail } from './imageThumbnail';
import { setFullImage } from './fullImageCache.svelte';
import ImagePlus from '@lucide/svelte/icons/image-plus';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import AlertTriangle from '@lucide/svelte/icons/triangle-alert';
import X from '@lucide/svelte/icons/x';
import '$lib/css/action_button.css';
type Status = 'pending' | 'placed' | 'unplaced' | 'matched' | 'error';
type Entry = {
id: string;
name: string;
status: Status;
message?: string;
};
interface Props {
/** Called when the user picks/drops a `.gpx` file via the FAB.
* Owning page handles the import + draft-replacement confirm.
* When absent, GPX files are silently ignored. */
onGpxImport?: (file: File) => void;
}
const { onGpxImport }: Props = $props();
function isGpxFile(file: File): boolean {
if (file.name.toLowerCase().endsWith('.gpx')) return true;
return (
file.type === 'application/gpx+xml' ||
file.type === 'application/xml' ||
file.type === 'text/xml'
);
}
let entries = $state<Entry[]>([]);
let isDragging = $state(false);
let showFailDetails = $state(false);
const orphanImageCount = $derived(
builder.waypoints.filter((w) => w.imageHash && !w.thumbnail).length
);
const pendingCount = $derived(entries.filter((e) => e.status === 'pending').length);
const failedEntries = $derived(
entries.filter((e) => e.status === 'error' || e.status === 'unplaced')
);
const failCount = $derived(failedEntries.length);
// Numeric badge on the FAB. Pending wins (in-flight work), then
// failures (need attention), then orphan hash-only waypoints from a
// GPX import (waiting for their source images).
const badge = $derived(
pendingCount > 0
? pendingCount
: failCount > 0
? failCount
: orphanImageCount > 0
? orphanImageCount
: 0
);
const badgeTone = $derived<'pending' | 'fail' | 'info'>(
pendingCount > 0 ? 'pending' : failCount > 0 ? 'fail' : 'info'
);
type Prepared =
| { ok: true; kind: 'new'; wp: Waypoint; hasGps: boolean; id: string; file: File }
| { ok: true; kind: 'matched'; id: string; file: File }
| { ok: false };
// Auto-clear successful entries after 4s so the badge counter doesn't
// pile up. Failures stay until the user dismisses them.
function scheduleAutoDismiss(id: string, ms = 4000) {
setTimeout(() => {
const e = entries.find((x) => x.id === id);
if (!e) return;
if (e.status === 'placed' || e.status === 'matched') dismiss(id);
}, ms);
}
async function handleFiles(files: File[]) {
const exifr = (await import('exifr')).default;
const prepared = await Promise.all(
files.map(async (file): Promise<Prepared> => {
const id = nextWaypointId();
const entryIdx = entries.length;
entries.push({ id, name: file.name, status: 'pending' });
try {
const exif = await exifr
.parse(file, { gps: true, exif: true })
.catch(() => null);
let thumbnail: string | undefined;
try {
thumbnail = await readThumbnail(file);
} catch { /* preview is optional */ }
const imageHash = await generateImageHashClient(file);
// Match path: re-attach to an existing waypoint with the
// same content hash (covers the GPX-roundtrip flow).
const existing = untrack(() =>
builder.waypoints.find((w) => w.imageHash === imageHash)
);
if (existing) {
if (thumbnail && !existing.thumbnail) existing.thumbnail = thumbnail;
if (!existing.imageVisibility) existing.imageVisibility = 'public';
scheduleSave();
entries[entryIdx].status = 'matched';
entries[entryIdx].message = existing.unplaced
? 'noch nicht auf der Karte platziert'
: undefined;
scheduleAutoDismiss(entries[entryIdx].id);
return { ok: true, kind: 'matched', id: existing.id, file };
}
const timestamp =
exif?.DateTimeOriginal instanceof Date ? exif.DateTimeOriginal.getTime() : null;
const hasGps =
exif &&
typeof exif.latitude === 'number' &&
typeof exif.longitude === 'number';
// EXIF GPSAltitude is intentionally ignored (too noisy);
// terrain-model altitude from Swisstopo is backfilled later.
const wp: Waypoint = hasGps
? {
id,
lat: exif.latitude,
lng: exif.longitude,
timestamp,
thumbnail,
imageHash,
imageVisibility: 'public'
}
: {
id,
lat: 0,
lng: 0,
timestamp,
thumbnail,
imageHash,
imageVisibility: 'public',
unplaced: true
};
entries[entryIdx].status = hasGps ? 'placed' : 'unplaced';
if (hasGps) scheduleAutoDismiss(entries[entryIdx].id);
return { ok: true, kind: 'new', wp, hasGps, id, file };
} catch (err) {
entries[entryIdx].status = 'error';
entries[entryIdx].message = (err as Error).message;
return { ok: false };
}
})
);
let placedAny = false;
for (const p of prepared) {
if (!p.ok) continue;
if (p.kind === 'new') {
insertWaypointChronologically(p.wp);
if (p.hasGps) placedAny = true;
}
setFullImage(p.id, p.file);
}
if (placedAny) requestFitBounds();
}
function routeFiles(files: File[]) {
const gpx = files.find(isGpxFile);
if (gpx && onGpxImport) {
// GPX import REPLACES the draft, so we hand off the first one
// and ignore everything else in the batch — combining a GPX
// import with an image batch would race the snap-to-route
// reactor against a draft reset.
onGpxImport(gpx);
return;
}
const images = files.filter((f) => f.type.startsWith('image/'));
if (images.length > 0) handleFiles(images);
}
function onDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
const files = [...(e.dataTransfer?.files ?? [])];
if (files.length > 0) routeFiles(files);
}
function onFileInput(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const files = [...(input.files ?? [])];
if (files.length > 0) routeFiles(files);
input.value = '';
}
function dismiss(entryId: string) {
const idx = entries.findIndex((e) => e.id === entryId);
if (idx >= 0) entries.splice(idx, 1);
}
function clearFailed() {
entries = entries.filter((e) => e.status !== 'error' && e.status !== 'unplaced');
showFailDetails = false;
}
let fileInput: HTMLInputElement | undefined = $state();
function openPicker() {
fileInput?.click();
}
</script>
<div
class="bulk-fab-wrap"
class:dragging={isDragging}
role="region"
aria-label="Bilder-Upload"
ondragenter={(e) => {
const types = e.dataTransfer?.types;
if (types && Array.from(types).includes('Files')) {
e.preventDefault();
isDragging = true;
}
}}
ondragover={(e) => {
const types = e.dataTransfer?.types;
if (types && Array.from(types).includes('Files')) {
e.preventDefault();
}
}}
ondragleave={(e) => {
if (e.currentTarget === e.target) isDragging = false;
}}
ondrop={onDrop}
>
<input
bind:this={fileInput}
type="file"
accept="image/*,.gpx,application/gpx+xml,application/xml,text/xml"
multiple
onchange={onFileInput}
hidden
/>
<button
type="button"
class="bulk-fab action_button"
aria-label="Bilder oder GPX hinzufügen"
title="Bilder oder GPX hinzufügen"
onclick={openPicker}
>
{#if pendingCount > 0}
<LoaderCircle size={30} strokeWidth={2.2} color="white" class="bulk-fab-icon spin" />
{:else}
<ImagePlus size={30} strokeWidth={2.2} color="white" class="bulk-fab-icon" />
{/if}
</button>
{#if failCount > 0}
<button
type="button"
class="bulk-fab-badge tone-fail"
onclick={() => (showFailDetails = !showFailDetails)}
aria-label="{failCount} {failCount === 1 ? 'Hinweis' : 'Hinweise'} anzeigen"
aria-expanded={showFailDetails}
>
{badge}
</button>
{:else if badge > 0}
<span class="bulk-fab-badge tone-{badgeTone}" aria-label="{badge} aktiv">
{badge}
</span>
{/if}
</div>
{#if showFailDetails && failedEntries.length > 0}
<aside class="bulk-fail-popover" aria-label="Bild-Hinweise">
<header>
<strong>Bild-Hinweise</strong>
<button type="button" class="link" onclick={clearFailed}>Alle ausblenden</button>
</header>
<ul>
{#each failedEntries as e (e.id)}
<li class="bulk-fail status-{e.status}">
<span class="status-icon" aria-hidden="true">
<AlertTriangle size={12} strokeWidth={2} />
</span>
<span class="name">{e.name}</span>
<span class="msg">
{#if e.status === 'unplaced'}Position fehlt — Eintrag in der Wegpunktliste auf Karte platzieren.
{:else}Fehler: {e.message ?? 'unbekannt'}
{/if}
</span>
<button type="button" class="dismiss" aria-label="Schließen" onclick={() => dismiss(e.id)}>
<X size={13} strokeWidth={2} />
</button>
</li>
{/each}
</ul>
</aside>
{/if}
<style>
/* Wrapper holds the FAB + badge in a single positioning context so the
* drag-target (full wrapper bounds) is larger than the button itself —
* helps users dropping a stack of images. */
.bulk-fab-wrap {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 3.75rem;
height: 3.75rem;
z-index: 100;
transition: transform var(--transition-normal);
}
.bulk-fab-wrap.dragging {
transform: scale(1.08);
}
/* FAB — mirrors the recipes-style ActionButton (same shake + shadow
* via the shared action_button.css). */
.bulk-fab {
width: 100%;
height: 100%;
padding: 0;
border-radius: var(--radius-pill);
background-color: var(--red);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition-normal);
}
.bulk-fab :global(.bulk-fab-icon) {
pointer-events: none;
}
.bulk-fab :global(.bulk-fab-icon.spin) {
animation: bulk-fab-spin 0.85s linear infinite;
}
@keyframes bulk-fab-spin {
to { transform: rotate(360deg); }
}
.bulk-fab-wrap.dragging .bulk-fab {
background-color: var(--nord0);
box-shadow: 0 0 0 5px color-mix(in oklab, var(--red) 35%, transparent),
0 0 1.6em 0.4em rgba(0, 0, 0, 0.35);
}
@media (max-width: 500px) {
.bulk-fab-wrap {
bottom: 1rem;
right: 1rem;
width: 3.25rem;
height: 3.25rem;
}
}
/* Numeric badge — pinned top-right of the FAB. Pending = primary blue,
* fail = orange, info (orphan hashes) = nord blue. */
.bulk-fab-badge {
position: absolute;
top: -0.25rem;
right: -0.25rem;
min-width: 1.35rem;
height: 1.35rem;
padding: 0 0.35rem;
border-radius: var(--radius-pill);
background: var(--color-primary);
color: var(--color-text-on-primary);
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
justify-content: center;
border: 2px solid var(--color-surface);
box-shadow: var(--shadow-sm);
appearance: none;
font-family: inherit;
}
button.bulk-fab-badge {
cursor: pointer;
}
.bulk-fab-badge.tone-fail {
background: var(--orange);
}
.bulk-fab-badge.tone-info {
background: var(--blue);
}
/* Failure popover anchored above the FAB. Only opens when the user
* clicks the fail-tinted badge, so the FAB itself stays minimal. */
.bulk-fail-popover {
position: fixed;
bottom: 6.5rem;
right: 2rem;
z-index: 101;
max-width: min(360px, calc(100vw - 3rem));
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 3px solid var(--orange);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 0.6rem 0.7rem;
animation: bulk-fail-in 200ms ease-out;
}
@keyframes bulk-fail-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bulk-fail-popover header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-primary);
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.4rem;
}
.bulk-fail-popover .link {
appearance: none;
background: transparent;
border: 0;
font: inherit;
font-size: 0.75rem;
color: var(--color-text-tertiary);
cursor: pointer;
text-decoration: underline;
}
.bulk-fail-popover ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.bulk-fail {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
align-items: center;
padding: 0.35rem 0.4rem;
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
font-size: 0.78rem;
color: var(--color-text-secondary);
}
.bulk-fail .status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: var(--orange);
color: white;
flex-shrink: 0;
}
.bulk-fail.status-error .status-icon {
background: var(--red);
}
.bulk-fail .name {
grid-column: 2;
grid-row: 1;
color: var(--color-text-primary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bulk-fail .msg {
grid-column: 2;
grid-row: 2;
font-size: 0.72rem;
color: var(--color-text-tertiary);
line-height: 1.35;
}
.bulk-fail.status-error .msg {
color: var(--red);
}
.bulk-fail .dismiss {
grid-column: 3;
grid-row: 1 / span 2;
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-tertiary);
padding: 0.2rem;
border-radius: var(--radius-sm);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bulk-fail .dismiss:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
</style>
@@ -0,0 +1,186 @@
<script lang="ts">
import { builder } from './builderStore.svelte';
import { haversineKm } from '$lib/gpx';
import { computeElevationStats, computeElevationRange } from '$lib/hikes/elevation';
import Route from '@lucide/svelte/icons/route';
import TrendingUp from '@lucide/svelte/icons/trending-up';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import ArrowUpToLine from '@lucide/svelte/icons/arrow-up-to-line';
import ArrowDownToLine from '@lucide/svelte/icons/arrow-down-to-line';
interface Props {
/** True while the snap-to-route / elevation-enrichment pipeline is still
* resolving. Stats are computed from whatever's already in the store so
* the user sees an evolving preview; the flag drives a subtle pulse so
* they know the numbers may still tick up. */
busy?: boolean;
}
const { busy = false }: Props = $props();
type Pt = { lat: number; lng: number; altitude?: number };
// Flatten routedSegments → trkpt-shaped array. We dedupe the seam between
// adjacent segments (each segment repeats its end as the next segment's
// start) so distance + elevation don't double-count those vertices.
const flatTrack = $derived.by<Pt[]>(() => {
const out: Pt[] = [];
let prev: Pt | null = null;
for (const seg of builder.routedSegments) {
for (const p of seg) {
const point: Pt = {
lng: p[0],
lat: p[1],
altitude: typeof p[2] === 'number' ? p[2] : undefined
};
if (
prev &&
prev.lat === point.lat &&
prev.lng === point.lng &&
prev.altitude === point.altitude
) {
continue;
}
out.push(point);
prev = point;
}
}
return out;
});
const distanceKm = $derived.by(() => {
let total = 0;
for (let i = 1; i < flatTrack.length; i++) {
total += haversineKm(
{ ...flatTrack[i - 1], timestamp: 0 },
{ ...flatTrack[i], timestamp: 0 }
);
}
return total;
});
const elevStats = $derived(computeElevationStats(flatTrack));
const elevRange = $derived(computeElevationRange(flatTrack));
const hasTrack = $derived(flatTrack.length >= 2);
function fmtNum(n: number | null | undefined, suffix = ''): string {
if (n === null || n === undefined) return '';
return `${n}${suffix}`;
}
</script>
<section class="stats-bar" class:busy class:idle={!hasTrack} aria-label="Routendaten">
<div class="metric">
<Route size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">
{hasTrack ? distanceKm.toFixed(1) : ''}<span class="value-unit">km</span>
</span>
<span class="unit">Distanz</span>
</div>
<div class="metric">
<TrendingUp size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">
{hasTrack ? fmtNum(elevStats.gain) : ''}<span class="value-unit">m</span>
</span>
<span class="unit">Aufstieg</span>
</div>
<div class="metric">
<TrendingDown size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">
{hasTrack ? fmtNum(elevStats.loss) : ''}<span class="value-unit">m</span>
</span>
<span class="unit">Abstieg</span>
</div>
<div class="metric">
<ArrowUpToLine size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">
{hasTrack ? fmtNum(elevRange.max) : ''}<span class="value-unit">m</span>
</span>
<span class="unit">höchster</span>
</div>
<div class="metric">
<ArrowDownToLine size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">
{hasTrack ? fmtNum(elevRange.min) : ''}<span class="value-unit">m</span>
</span>
<span class="unit">tiefster</span>
</div>
</section>
<style>
.stats-bar {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem 2rem;
padding: 1rem 1.25rem;
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
color: var(--color-text-secondary);
font-size: 0.9rem;
transition: opacity 200ms ease;
}
.stats-bar.idle {
color: var(--color-text-tertiary);
}
.stats-bar.busy {
animation: stats-pulse 1.6s ease-in-out infinite;
}
@keyframes stats-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
@media (prefers-reduced-motion: reduce) {
.stats-bar.busy {
animation: none;
}
}
.metric {
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto;
column-gap: 0.55rem;
row-gap: 0.05rem;
align-items: center;
}
.metric :global(svg) {
grid-row: 1 / span 2;
color: var(--color-primary);
}
.stats-bar.idle .metric :global(svg) {
color: var(--color-text-tertiary);
}
.value {
font-size: 1.25rem;
line-height: 1.1;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.stats-bar.idle .value {
color: var(--color-text-tertiary);
}
.value-unit {
font-size: 0.7em;
font-weight: 500;
color: var(--color-text-secondary);
margin-left: 0.15em;
}
.unit {
font-size: 0.75rem;
color: var(--color-text-tertiary);
letter-spacing: 0.02em;
}
</style>
@@ -0,0 +1,813 @@
<script lang="ts">
import {
builder,
focusWaypoint,
mapView,
placedSequence,
scheduleSave,
toggleStageBreak,
renameStage
} from './builderStore.svelte';
import { generateImageHashClient } from '$lib/imageHashClient';
import { readThumbnail } from './imageThumbnail';
import { dropFullImage, getFullImageUrl, setFullImage } from './fullImageCache.svelte';
import DateTimePicker from '$lib/components/DateTimePicker.svelte';
import MapPinned from '@lucide/svelte/icons/map-pinned';
import ImagePlus from '@lucide/svelte/icons/image-plus';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import Globe from '@lucide/svelte/icons/globe';
import Lock from '@lucide/svelte/icons/lock';
import X from '@lucide/svelte/icons/x';
import Flag from '@lucide/svelte/icons/flag';
interface Props {
onCancelPlacement?: () => void;
}
const { onCancelPlacement }: Props = $props();
const NUDGE_MINUTES = [-10, -5, 5, 10];
// Drive everything off the focus signal. The full waypoint array index
// (`idx`) is used for in-place mutation; `wp` is a reactive reference into
// the same store entry so writes propagate via Svelte 5 deep reactivity.
const wpIdx = $derived(
mapView.focusId ? builder.waypoints.findIndex((w) => w.id === mapView.focusId) : -1
);
const wp = $derived(wpIdx === -1 ? null : builder.waypoints[wpIdx]);
const seq = $derived(wp ? placedSequence(wp.id) : null);
const placed = $derived(builder.waypoints.filter((w) => !w.unplaced));
const firstPlacedIdx = $derived(builder.waypoints.findIndex((w) => !w.unplaced));
const lastPlacedIdx = $derived.by(() => {
for (let i = builder.waypoints.length - 1; i >= 0; i--) {
if (!builder.waypoints[i].unplaced) return i;
}
return -1;
});
const requiresTime = $derived(wpIdx !== -1 && (wpIdx === firstPlacedIdx || wpIdx === lastPlacedIdx));
// Stage info for the focused waypoint (multi-day hikes). Mirrors the
// per-row control in the waypoint list.
const stageInfo = $derived.by(() => {
if (!wp) return null;
let num = 0;
let name = '';
for (let i = 0; i < placed.length; i++) {
const w = placed[i];
const isStart = i === 0 || w.stageStart !== undefined;
if (isStart) {
num++;
name = w.stageStart || `Etappe ${num}`;
}
if (w.id === wp.id) return { isStart, num, name, isFirst: i === 0 };
}
return null;
});
const stageCount = $derived(
placed.reduce((n, w, i) => n + (i === 0 || w.stageStart !== undefined ? 1 : 0), 0)
);
function nearestTimestamp(idx: number): number | undefined {
const wps = builder.waypoints;
for (let dist = 1; dist < wps.length; dist++) {
const a = wps[idx - dist];
if (a && typeof a.timestamp === 'number') return a.timestamp;
const b = wps[idx + dist];
if (b && typeof b.timestamp === 'number') return b.timestamp;
}
return undefined;
}
const inheritedTs = $derived.by(() => {
if (!wp || wp.timestamp != null) return null;
return nearestTimestamp(wpIdx) ?? null;
});
function updateLat(raw: string) {
if (!wp) return;
const n = parseFloat(raw);
if (!isNaN(n)) {
wp.lat = n;
scheduleSave();
}
}
function updateLng(raw: string) {
if (!wp) return;
const n = parseFloat(raw);
if (!isNaN(n)) {
wp.lng = n;
scheduleSave();
}
}
function setVisibility(value: 'public' | 'private') {
if (!wp) return;
wp.imageVisibility = value;
scheduleSave();
}
function removeWaypoint() {
if (!wp || wpIdx === -1) return;
const id = wp.id;
dropFullImage(id);
builder.waypoints.splice(wpIdx, 1);
scheduleSave();
// Move focus to the next remaining placed waypoint, or clear it.
const next = placed.find((w) => w.id !== id);
focusWaypoint(next?.id ?? null);
}
function closePanel() {
focusWaypoint(null);
}
let attachBusy = $state(false);
let dragActive = $state(false);
async function attachImage(fileList: FileList | null) {
const file = fileList?.[0];
if (!file || !wp) return;
attachBusy = true;
try {
const exifr = (await import('exifr')).default;
const exif = await exifr.parse(file, { gps: true, exif: true }).catch(() => null);
const hash = await generateImageHashClient(file);
let thumbnail: string | undefined;
try {
thumbnail = await readThumbnail(file);
} catch { /* thumbnail is optional */ }
wp.imageHash = hash;
wp.thumbnail = thumbnail;
wp.imageVisibility = 'public';
setFullImage(wp.id, file);
if (wp.timestamp == null && exif?.DateTimeOriginal instanceof Date) {
wp.timestamp = exif.DateTimeOriginal.getTime();
}
scheduleSave();
} finally {
attachBusy = false;
}
}
function onHeroDrop(e: DragEvent) {
e.preventDefault();
dragActive = false;
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
const imgs = [...files].filter((f) => f.type.startsWith('image/'));
if (imgs.length === 0) return;
const dt = new DataTransfer();
dt.items.add(imgs[0]);
attachImage(dt.files);
}
</script>
<aside class="detail-panel" aria-label="Wegpunkt-Details">
{#if !wp}
<div class="empty">
<MapPinned size={32} strokeWidth={1.5} />
<p class="empty-title">Kein Wegpunkt ausgewählt</p>
<p class="empty-sub">
Klicke einen Pin auf der Karte oder einen Eintrag in der Liste an, um ihn
hier zu bearbeiten. Mit ← / → kannst du die Route Wegpunkt für Wegpunkt
durchgehen.
</p>
</div>
{:else}
<header class="panel-head">
<span class="seq" class:unplaced={wp.unplaced}>{seq ?? '?'}</span>
<h3 class="title">
{#if wp.unplaced}
Bild ohne Position
{:else if wp.imageHash}
Bild {seq}
{:else}
Wegpunkt {seq}
{/if}
</h3>
<button
type="button"
class="close"
onclick={closePanel}
aria-label="Panel schließen"
title="Schließen"
>
<X size={16} strokeWidth={2} />
</button>
</header>
<div class="hero" class:empty={!wp.thumbnail && !getFullImageUrl(wp.id)} class:busy={attachBusy}>
{#if wp.thumbnail || getFullImageUrl(wp.id)}
<img src={getFullImageUrl(wp.id) ?? wp.thumbnail} alt="" />
{:else}
<!-- Same 4:3 box as the thumbnail variant so the rest of the panel
stays put when an image gets attached. The label fills the box,
acts as both click target and drop target. -->
<label
class="hero-upload"
class:drag={dragActive}
ondragenter={(e) => { e.preventDefault(); dragActive = true; }}
ondragover={(e) => { e.preventDefault(); }}
ondragleave={() => { dragActive = false; }}
ondrop={onHeroDrop}
>
<input
type="file"
accept="image/*"
disabled={attachBusy}
onchange={(e) => attachImage(e.currentTarget.files)}
/>
<span class="hero-upload-inner">
{#if attachBusy}
<LoaderCircle size={28} strokeWidth={1.75} class="spin" />
<span class="hero-upload-title">Bild wird gelesen…</span>
{:else}
<ImagePlus size={28} strokeWidth={1.75} />
<span class="hero-upload-title">Bild anhängen</span>
<span class="hero-upload-sub">
Klicken oder hierher ziehen
</span>
{/if}
</span>
</label>
{/if}
</div>
{#if wp.imageHash}
<div class="vis-block" class:is-private={wp.imageVisibility === 'private'}>
<div class="vis-head">
<span class="label">Sichtbarkeit auf der Website</span>
<span class="vis-state">
{wp.imageVisibility === 'private'
? 'Nur du siehst dieses Bild im veröffentlichten GPX.'
: 'Dieses Bild wird öffentlich auf der Wandereintragsseite angezeigt.'}
</span>
</div>
<div class="vis-segment" role="radiogroup" aria-label="Sichtbarkeit">
<button
type="button"
class="vis-opt"
class:active={wp.imageVisibility !== 'private'}
aria-pressed={wp.imageVisibility !== 'private'}
onclick={() => setVisibility('public')}
>
<Globe size={18} strokeWidth={2} />
<span>Öffentlich</span>
</button>
<button
type="button"
class="vis-opt"
class:active={wp.imageVisibility === 'private'}
aria-pressed={wp.imageVisibility === 'private'}
onclick={() => setVisibility('private')}
>
<Lock size={18} strokeWidth={2} />
<span>Privat</span>
</button>
</div>
</div>
{/if}
{#if !wp.unplaced}
<div class="field">
<span class="label">
{requiresTime ? 'Zeit (Pflicht)' : 'Zeit'}
</span>
<DateTimePicker
bind:value={builder.waypoints[wpIdx].timestamp}
mode={wp.imageHash || requiresTime || wp.timestamp != null ? 'datetime' : 'date'}
inheritedValue={inheritedTs}
nudgeMinutes={NUDGE_MINUTES}
required={requiresTime}
lang="de"
/>
</div>
{:else}
<p class="placement-hint">
Diese Position fehlt noch. Wähle den Eintrag in der Wegpunktliste unten und
klicke „Auf Karte platzieren“ oder ziehe ein Bild mit GPS-EXIF in den
Bildbereich.
</p>
<button type="button" class="ghost" onclick={() => onCancelPlacement?.()}>
Platzierung abbrechen
</button>
{/if}
{#if !wp.unplaced}
{#if stageInfo?.isStart && stageCount > 1}
<div class="stage-block">
<span class="stage-cap"><Flag size={12} strokeWidth={2.25} />Etappe {stageInfo.num}</span>
<input
class="stage-name-input"
value={wp.stageStart ?? stageInfo.name}
placeholder={`Etappe ${stageInfo.num}`}
oninput={(e) => renameStage(wp.id, e.currentTarget.value)}
aria-label={`Name Etappe ${stageInfo.num}`}
/>
{#if !stageInfo.isFirst}
<button type="button" class="stage-dissolve" onclick={() => toggleStageBreak(wp.id)}>
Etappe auflösen
</button>
{/if}
</div>
{:else if !stageInfo?.isFirst}
<button type="button" class="stage-new" onclick={() => toggleStageBreak(wp.id)}>
<Flag size={15} strokeWidth={2} />
<span>Neue Etappe ab hier</span>
</button>
{/if}
{/if}
{#if !wp.unplaced}
<details class="coords-details">
<summary>Koordinaten anpassen</summary>
<div class="coords-grid">
<div class="field">
<label class="label" for="dp-lat">Breitengrad</label>
<input
id="dp-lat"
type="number"
step="0.000001"
value={wp.lat}
onchange={(e) => updateLat(e.currentTarget.value)}
/>
</div>
<div class="field">
<label class="label" for="dp-lng">Längengrad</label>
<input
id="dp-lng"
type="number"
step="0.000001"
value={wp.lng}
onchange={(e) => updateLng(e.currentTarget.value)}
/>
</div>
</div>
</details>
{/if}
<button type="button" class="danger" onclick={removeWaypoint}>
Wegpunkt entfernen
</button>
{/if}
</aside>
<style>
.detail-panel {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 1rem;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 0.65rem;
min-width: 0;
/* Match the map's height so the column visually anchors next to it.
* The intrinsic content scrolls within so the panel itself stays the
* same shape regardless of waypoint state. */
max-height: 640px;
overflow-y: auto;
}
@media (max-width: 900px) {
.detail-panel {
max-height: none;
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.4rem;
padding: 1.5rem 0.75rem;
color: var(--color-text-tertiary);
}
.empty-title {
margin: 0.3rem 0 0;
font-size: 0.95rem;
color: var(--color-text-secondary);
font-weight: 600;
}
.empty-sub {
margin: 0;
font-size: 0.8rem;
line-height: 1.45;
}
.panel-head {
display: flex;
align-items: center;
gap: 0.6rem;
}
.seq {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 0.45em;
background: var(--color-primary);
color: var(--color-text-on-primary);
border-radius: 14px;
font-size: 0.8rem;
font-weight: 700;
}
.seq.unplaced {
background: var(--orange);
}
.title {
flex: 1 1 auto;
min-width: 0;
margin: 0;
font-size: 1rem;
color: var(--color-text-primary);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.close {
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-tertiary);
padding: 0.25rem;
border-radius: var(--radius-sm);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.close:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.hero {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-bg-elevated);
}
.hero img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Empty-state variant occupies the SAME 4:3 box as the thumbnail
* variant — same width, same aspect-ratio, same border-radius — so
* dropping/attaching an image swaps the inner content without shifting
* any other panel section. */
.hero.empty {
background: linear-gradient(
135deg,
color-mix(in oklab, var(--color-primary) 6%, transparent),
transparent 70%
),
var(--color-bg-tertiary);
border: 1.5px dashed color-mix(in oklab, var(--color-primary) 32%, var(--color-border));
transition: border-color var(--transition-fast), background var(--transition-fast);
}
.hero.empty:hover {
border-color: var(--color-primary);
}
.hero-upload {
display: flex;
width: 100%;
height: 100%;
cursor: pointer;
}
.hero.busy .hero-upload {
cursor: wait;
}
.hero-upload input[type='file'] {
display: none;
}
.hero-upload.drag {
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
}
.hero-upload-inner {
flex: 1 1 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.75rem;
color: var(--color-text-secondary);
pointer-events: none;
}
.hero-upload-inner :global(svg) {
color: var(--color-primary);
}
.hero-upload-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-primary);
}
.hero-upload-sub {
font-size: 0.78rem;
color: var(--color-text-tertiary);
}
.hero-upload :global(.spin) {
animation: panel-spin 0.85s linear infinite;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.label {
font-size: 0.72rem;
color: var(--color-text-tertiary);
letter-spacing: 0.04em;
text-transform: uppercase;
font-weight: 600;
}
.field input[type='number'] {
width: 100%;
padding: 0.45rem 0.6rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font: inherit;
font-size: 0.85rem;
}
/* Visibility is the highest-stakes setting in the panel — privacy choice
* for the published GPX. Treat it as a primary action: card-like block
* with a short rationale + a wide two-segment toggle, tinted green for
* public and amber for private so the current state reads at a glance. */
.vis-block {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.75rem 0.85rem 0.85rem;
background: color-mix(in oklab, var(--green) 8%, var(--color-bg-secondary));
border: 1px solid color-mix(in oklab, var(--green) 30%, var(--color-border));
border-left: 3px solid var(--green);
border-radius: var(--radius-md);
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.vis-block.is-private {
background: color-mix(in oklab, var(--orange) 8%, var(--color-bg-secondary));
border-color: color-mix(in oklab, var(--orange) 35%, var(--color-border));
border-left-color: var(--orange);
}
.vis-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.vis-state {
font-size: 0.78rem;
color: var(--color-text-secondary);
line-height: 1.35;
}
.vis-segment {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem;
}
.vis-opt {
appearance: none;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
padding: 0.6rem 0.5rem;
border-radius: var(--radius-md);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
transition: background var(--transition-fast), border-color var(--transition-fast),
color var(--transition-fast), box-shadow var(--transition-fast);
}
.vis-opt:hover:not(.active) {
background: var(--color-bg-elevated);
}
.vis-opt.active {
color: white;
}
.vis-block:not(.is-private) .vis-opt.active {
background: var(--green);
border-color: var(--green);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--green) 30%, transparent);
}
.vis-block.is-private .vis-opt.active {
background: var(--orange);
border-color: var(--orange);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--orange) 30%, transparent);
}
/* Stage controls — start a new stage at this waypoint, or name/dissolve an
* existing stage start. Mirrors the waypoint-list affordance. */
.stage-new {
appearance: none;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.5rem 0.9rem;
border-radius: var(--radius-pill);
background: transparent;
border: 1px dashed color-mix(in oklab, var(--color-primary) 40%, var(--color-border));
color: var(--color-primary);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast),
color var(--transition-fast);
}
.stage-new:hover {
background: var(--color-primary);
border-style: solid;
border-color: var(--color-primary);
color: var(--color-text-on-primary);
}
.stage-block {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 0.7rem;
background: color-mix(in oklab, var(--color-primary) 8%, var(--color-bg-secondary));
border: 1px solid color-mix(in oklab, var(--color-primary) 25%, var(--color-border));
border-left: 3px solid var(--color-primary);
border-radius: var(--radius-md);
}
.stage-cap {
display: inline-flex;
align-items: center;
gap: 0.3rem;
flex: 0 0 auto;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-primary);
}
.stage-name-input {
flex: 1 1 auto;
min-width: 0;
padding: 0.35rem 0.5rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font: inherit;
font-size: 0.85rem;
font-weight: 600;
}
.stage-dissolve {
flex: 0 0 auto;
appearance: none;
font: inherit;
font-size: 0.72rem;
padding: 0.3rem 0.5rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
white-space: nowrap;
}
.stage-dissolve:hover {
color: var(--red);
border-color: color-mix(in oklab, var(--red) 40%, var(--color-border));
}
/* Coords are a power-user adjustment — keep them out of the way unless
* the user explicitly opens the disclosure. Dragging the marker on the
* map is the primary editing affordance. */
.coords-details {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
}
.coords-details > summary {
cursor: pointer;
padding: 0.5rem 0.75rem;
font-size: 0.78rem;
color: var(--color-text-secondary);
font-weight: 600;
letter-spacing: 0.01em;
list-style: revert;
}
.coords-details[open] > summary {
border-bottom: 1px solid var(--color-border);
}
.coords-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
padding: 0.6rem 0.75rem 0.7rem;
}
@media (max-width: 360px) {
.coords-grid {
grid-template-columns: 1fr;
}
}
.placement-hint {
margin: 0;
padding: 0.5rem 0.7rem;
background: color-mix(in oklab, var(--orange) 10%, var(--color-bg-secondary));
border-left: 3px solid var(--orange);
border-radius: var(--radius-sm);
font-size: 0.8rem;
color: var(--color-text-secondary);
line-height: 1.45;
}
@keyframes panel-spin {
to { transform: rotate(360deg); }
}
.ghost {
appearance: none;
font: inherit;
font-size: 0.8rem;
padding: 0.4rem 0.9rem;
border-radius: var(--radius-pill);
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
cursor: pointer;
}
.danger {
appearance: none;
font: inherit;
font-size: 0.8rem;
padding: 0.5rem 0.9rem;
margin-top: 0.25rem;
border-radius: var(--radius-pill);
background: transparent;
border: 1px solid color-mix(in oklab, var(--red) 35%, var(--color-border));
color: var(--red);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.danger:hover {
background: var(--red);
color: white;
border-color: var(--red);
}
</style>
@@ -0,0 +1,854 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import {
builder,
focusWaypoint,
mapView,
placedSequence,
scheduleSave,
toggleStageBreak,
renameStage
} from './builderStore.svelte';
import { generateImageHashClient } from '$lib/imageHashClient';
import { readThumbnail } from './imageThumbnail';
import { dropFullImage, getFullImageUrl, setFullImage } from './fullImageCache.svelte';
import DateTimePicker from '$lib/components/DateTimePicker.svelte';
import ImagePlus from '@lucide/svelte/icons/image-plus';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import X from '@lucide/svelte/icons/x';
import Crosshair from '@lucide/svelte/icons/crosshair';
import MapPin from '@lucide/svelte/icons/map-pin';
import MapPinOff from '@lucide/svelte/icons/map-pin-off';
import Globe from '@lucide/svelte/icons/globe';
import Lock from '@lucide/svelte/icons/lock';
import Flag from '@lucide/svelte/icons/flag';
const NUDGE_MINUTES = [-10, -5, 5, 10];
interface Props {
/** The id of the waypoint currently in "place me on the map" mode.
* Used to highlight the active row. */
pendingPlacementId?: string | null;
onRequestPlacement?: (waypointId: string) => void;
onCancelPlacement?: () => void;
}
const { pendingPlacementId = null, onRequestPlacement, onCancelPlacement }: Props = $props();
// Index of the first / last *placed* waypoint — those are the ones that
// need a timestamp for the GPX export's interpolation.
const firstPlacedIdx = $derived(
builder.waypoints.findIndex((w) => !w.unplaced)
);
const lastPlacedIdx = $derived.by(() => {
for (let i = builder.waypoints.length - 1; i >= 0; i--) {
if (!builder.waypoints[i].unplaced) return i;
}
return -1;
});
// Per-waypoint stage metadata (placed waypoints only): whether it begins a
// stage, the stage number/name, and whether it's the route start.
const stageMeta = $derived.by(() => {
const map = new Map<string, { isStart: boolean; num: number; name: string; first: boolean }>();
let num = 0;
let name = '';
let firstSeen = false;
for (const w of builder.waypoints) {
if (w.unplaced) continue;
const first = !firstSeen;
firstSeen = true;
const isStart = first || w.stageStart !== undefined;
if (isStart) {
num++;
name = w.stageStart || `Etappe ${num}`;
}
map.set(w.id, { isStart, num, name, first });
}
return map;
});
const stageCount = $derived(
[...stageMeta.values()].filter((m) => m.isStart).length
);
/** Find the nearest waypoint *by index* that already carries a timestamp.
* Used as the `inheritedValue` for click waypoints — searching by sequence
* position (rather than geography) mirrors how authors typically insert
* waypoints (between existing ones, in trail order). */
function nearestTimestamp(idx: number): number | undefined {
const wps = builder.waypoints;
for (let dist = 1; dist < wps.length; dist++) {
const a = wps[idx - dist];
if (a && typeof a.timestamp === 'number') return a.timestamp;
const b = wps[idx + dist];
if (b && typeof b.timestamp === 'number') return b.timestamp;
}
return undefined;
}
// DateTimePicker mutates `builder.waypoints[i].timestamp` through $bindable
// — there's no per-change callback, so persist via reactivity instead.
// Reads every timestamp inside a tracked $effect; any write triggers the
// re-run and a debounced localStorage save.
$effect(() => {
for (const wp of builder.waypoints) {
void wp.timestamp;
}
scheduleSave();
});
function move(idx: number, delta: number) {
const next = idx + delta;
if (next < 0 || next >= builder.waypoints.length) return;
const [w] = builder.waypoints.splice(idx, 1);
builder.waypoints.splice(next, 0, w);
scheduleSave();
}
function remove(idx: number) {
const wp = builder.waypoints[idx];
dropFullImage(wp.id);
builder.waypoints.splice(idx, 1);
scheduleSave();
}
function updateLat(idx: number, raw: string) {
const n = parseFloat(raw);
if (!isNaN(n)) {
builder.waypoints[idx].lat = n;
scheduleSave();
}
}
function updateLng(idx: number, raw: string) {
const n = parseFloat(raw);
if (!isNaN(n)) {
builder.waypoints[idx].lng = n;
scheduleSave();
}
}
function setVisibility(idx: number, value: 'public' | 'private') {
builder.waypoints[idx].imageVisibility = value;
scheduleSave();
}
let attachBusy = $state<Record<string, boolean>>({});
async function attachImage(idx: number, fileList: FileList | null) {
const file = fileList?.[0];
if (!file) return;
const wp = builder.waypoints[idx];
attachBusy[wp.id] = true;
try {
const exifr = (await import('exifr')).default;
const exif = await exifr.parse(file, { gps: true, exif: true }).catch(() => null);
const hash = await generateImageHashClient(file);
let thumbnail: string | undefined;
try {
thumbnail = await readThumbnail(file);
} catch { /* thumbnail is optional */ }
wp.imageHash = hash;
wp.thumbnail = thumbnail;
wp.imageVisibility = 'public';
setFullImage(wp.id, file);
if (wp.timestamp == null && exif?.DateTimeOriginal instanceof Date) {
wp.timestamp = exif.DateTimeOriginal.getTime();
}
// EXIF GPSAltitude is intentionally ignored — terrain-model altitude
// from Swisstopo (already set when this waypoint was placed on the
// map) is more accurate and avoids spikes in the elevation profile.
scheduleSave();
} finally {
attachBusy[wp.id] = false;
}
}
</script>
<section class="wp-table">
<header>
<h2>Wegpunkte ({builder.waypoints.length})</h2>
</header>
{#if builder.waypoints.length === 0}
<p class="empty">Klicke auf die Karte oder lade Bilder, um Wegpunkte zu setzen.</p>
{:else}
<p class="legend">* Erster und letzter platzierter Wegpunkt brauchen einen Zeitstempel.</p>
<ol>
{#each builder.waypoints as wp, idx (wp.id)}
{@const seq = placedSequence(wp.id)}
{@const sm = stageMeta.get(wp.id)}
<li
class="wp"
class:stage-start={stageCount > 1 && sm?.isStart}
class:unplaced={wp.unplaced}
class:active={wp.id === pendingPlacementId}
class:focused={wp.id === mapView.focusId && !wp.unplaced}
animate:flip={{ duration: 220 }}
>
{#if stageCount > 1 && sm?.isStart}
<div class="stage-band">
<span class="stage-badge"><Flag size={11} strokeWidth={2.25} />Etappe {sm.num}</span>
<input
class="stage-name"
value={wp.stageStart ?? sm.name}
placeholder={`Etappe ${sm.num}`}
oninput={(e) => renameStage(wp.id, e.currentTarget.value)}
aria-label={`Name Etappe ${sm.num}`}
/>
{#if !sm.first}
<button
type="button"
class="stage-merge"
onclick={() => toggleStageBreak(wp.id)}
title="Mit vorheriger Etappe zusammenführen"
aria-label="Etappe auflösen"
>
<X size={13} strokeWidth={2.25} />
</button>
{/if}
</div>
{/if}
{#if wp.thumbnail || getFullImageUrl(wp.id)}
<div class="hero">
<img
src={getFullImageUrl(wp.id) ?? wp.thumbnail}
alt=""
loading="lazy"
/>
{#if wp.unplaced}
<span class="hero-badge">
<MapPinOff size={12} strokeWidth={2} />
<span>noch nicht platziert</span>
</span>
{/if}
</div>
{/if}
<div class="row title-row">
<span class="idx" class:unplaced-idx={wp.unplaced}>
{seq ?? '?'}
</span>
<span class="title">
{#if wp.unplaced}
Bild ohne Position
{:else if wp.imageHash}
Bild {seq}
{:else}
Wegpunkt {seq}
{/if}
</span>
<div class="row-actions">
{#if !wp.unplaced && !sm?.first}
<button
type="button"
class="stage-flag"
class:on={sm?.isStart}
onclick={() => toggleStageBreak(wp.id)}
aria-pressed={sm?.isStart}
aria-label={sm?.isStart ? 'Etappenbeginn entfernen' : 'Neue Etappe ab hier'}
title={sm?.isStart ? 'Etappenbeginn entfernen' : 'Neue Etappe ab hier'}
>
<Flag size={14} strokeWidth={2} />
</button>
{/if}
{#if !wp.unplaced}
<button
type="button"
class="focus-btn"
onclick={() => focusWaypoint(wp.id)}
aria-label="Auf Karte fokussieren"
title="Auf Karte fokussieren"
>
<Crosshair size={14} strokeWidth={2} />
</button>
{/if}
<button
type="button"
onclick={() => move(idx, -1)}
disabled={idx === 0}
aria-label="Nach oben"
>
<ArrowUp size={14} strokeWidth={2} />
</button>
<button
type="button"
onclick={() => move(idx, 1)}
disabled={idx === builder.waypoints.length - 1}
aria-label="Nach unten"
>
<ArrowDown size={14} strokeWidth={2} />
</button>
<button
type="button"
class="del"
onclick={() => remove(idx)}
aria-label="Entfernen"
>
<X size={14} strokeWidth={2} />
</button>
</div>
</div>
{#if wp.unplaced}
<div class="row placement-row">
{#if wp.id === pendingPlacementId}
<span class="placing">Klicke auf die Karte…</span>
<button type="button" class="ghost" onclick={() => onCancelPlacement?.()}>Abbrechen</button>
{:else}
<button type="button" class="primary" onclick={() => onRequestPlacement?.(wp.id)}>
<MapPin size={14} strokeWidth={2} />
<span>Auf Karte platzieren</span>
</button>
{/if}
</div>
{:else}
<div class="row coords-row">
<input
type="number"
step="0.000001"
value={wp.lat}
onchange={(e) => updateLat(idx, e.currentTarget.value)}
aria-label="Breitengrad"
/>
<input
type="number"
step="0.000001"
value={wp.lng}
onchange={(e) => updateLng(idx, e.currentTarget.value)}
aria-label="Längengrad"
/>
</div>
{/if}
{#if !wp.unplaced}
{@const requiresTime = idx === firstPlacedIdx || idx === lastPlacedIdx}
{@const isImage = !!wp.imageHash}
{@const hasTimestamp = wp.timestamp != null}
{@const inheritedTs = !hasTimestamp ? nearestTimestamp(idx) ?? null : null}
{@const showTime = isImage || requiresTime || hasTimestamp}
<div class="row sub time-row">
<span class="time-cap">
{showTime ? 'Zeit' : 'Datum'}{requiresTime ? ' *' : ''}
</span>
<DateTimePicker
bind:value={builder.waypoints[idx].timestamp}
mode={showTime ? 'datetime' : 'date'}
inheritedValue={inheritedTs}
nudgeMinutes={showTime ? NUDGE_MINUTES : []}
required={requiresTime}
lang="de"
/>
</div>
{/if}
{#if wp.imageHash}
<div class="row image-visibility">
<span class="vis-label">Sichtbarkeit</span>
<div class="segment" role="radiogroup" aria-label="Sichtbarkeit">
<button
type="button"
class:active={wp.imageVisibility !== 'private'}
aria-pressed={wp.imageVisibility !== 'private'}
onclick={() => setVisibility(idx, 'public')}
>
<Globe size={12} strokeWidth={2} />
<span>Öffentlich</span>
</button>
<button
type="button"
class:active={wp.imageVisibility === 'private'}
aria-pressed={wp.imageVisibility === 'private'}
onclick={() => setVisibility(idx, 'private')}
>
<Lock size={12} strokeWidth={2} />
<span>Privat</span>
</button>
</div>
</div>
{:else if !wp.unplaced}
<label class="image-attach" class:busy={attachBusy[wp.id]}>
<input
type="file"
accept="image/*"
disabled={attachBusy[wp.id]}
onchange={(e) => attachImage(idx, e.currentTarget.files)}
/>
<span class="attach-cta">
{#if attachBusy[wp.id]}
<LoaderCircle size={15} strokeWidth={2} class="spin" />
<span>Bild wird gelesen…</span>
{:else}
<span class="attach-icon">
<ImagePlus size={15} strokeWidth={1.75} />
</span>
<span>Bild anhängen</span>
{/if}
</span>
</label>
{/if}
</li>
{/each}
</ol>
{/if}
</section>
<style>
.wp-table {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 1rem;
box-shadow: var(--shadow-sm);
}
header h2 {
margin: 0 0 0.75rem;
font-size: 1.1rem;
color: var(--color-text-primary);
}
.empty {
color: var(--color-text-tertiary);
font-size: 0.9rem;
margin: 0.5rem 0;
}
.legend {
margin: 0 0 0.6rem;
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
ol {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.75rem;
}
/* Stage band at the top of the first card of each stage. */
.stage-band {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.55rem;
background: color-mix(in oklab, var(--color-primary) 8%, var(--color-bg-secondary));
border-bottom: 1px solid color-mix(in oklab, var(--color-primary) 22%, var(--color-border));
}
.stage-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
flex: 0 0 auto;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-primary);
padding: 0.22rem 0.55rem;
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
border-radius: var(--radius-pill);
}
.stage-name {
flex: 1 1 auto;
min-width: 0;
padding: 0.3rem 0.5rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font: inherit;
font-size: 0.85rem;
font-weight: 600;
}
.stage-merge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
appearance: none;
padding: 0.25rem;
line-height: 0;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
}
.stage-merge:hover {
color: var(--red);
border-color: color-mix(in oklab, var(--red) 40%, var(--color-border));
}
.row-actions button.stage-flag.on {
color: var(--color-primary);
border-color: color-mix(in oklab, var(--color-primary) 40%, var(--color-border));
background: color-mix(in oklab, var(--color-primary) 10%, var(--color-bg-tertiary));
}
.wp {
padding: 0;
background: var(--color-bg-secondary);
border: 1px solid transparent;
border-radius: var(--radius-md);
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
scroll-margin-top: 1rem;
}
.wp.unplaced {
border-color: var(--orange);
background: color-mix(in oklab, var(--orange) 6%, var(--color-bg-secondary));
}
/* Mark the first card of each stage with a top accent. */
.wp.stage-start {
border-top: 2px solid var(--color-primary);
}
.wp.active {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 30%, transparent);
}
.wp.focused {
border-color: var(--blue);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--blue) 30%, transparent),
var(--shadow-md);
}
.hero {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
background: var(--color-bg-elevated);
overflow: hidden;
}
.hero img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-badge {
position: absolute;
top: 0.4rem;
left: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
background: var(--orange);
color: white;
font-size: 0.7rem;
font-weight: 600;
border-radius: var(--radius-pill);
box-shadow: var(--shadow-sm);
}
.row {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.4rem 0.6rem;
}
.row.title-row {
gap: 0.55rem;
}
.title {
flex: 1 1 0;
min-width: 0;
font-size: 0.85rem;
color: var(--color-text-primary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.idx {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.4em;
background: var(--color-primary);
color: var(--color-text-on-primary);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 700;
}
.idx.unplaced-idx {
background: var(--orange);
}
.coords-row {
display: flex;
gap: 0.3rem;
}
.coords-row input {
flex: 1 1 0;
min-width: 0;
}
.placement-row {
gap: 0.6rem;
flex-wrap: wrap;
}
.placement-row .placing {
flex: 1 1 auto;
font-size: 0.8rem;
color: var(--color-primary);
}
.row.sub {
flex-wrap: wrap;
font-size: 0.78rem;
color: var(--color-text-secondary);
}
.time-row {
flex-wrap: wrap;
row-gap: 0.4rem;
align-items: center;
}
.time-cap {
font-size: 0.72rem;
color: var(--color-text-tertiary);
letter-spacing: 0.02em;
flex-shrink: 0;
}
input[type='number'] {
width: 100%;
min-width: 0;
padding: 0.25rem 0.4rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font: inherit;
font-size: 0.8rem;
}
.row-actions {
display: flex;
gap: 0.2rem;
}
.row-actions button {
appearance: none;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font: inherit;
padding: 0.25rem;
border-radius: var(--radius-sm);
cursor: pointer;
line-height: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.row-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.row-actions button.del {
color: var(--red);
}
.row-actions button.focus-btn {
color: var(--blue);
border-color: color-mix(in oklab, var(--blue) 35%, var(--color-border));
background: color-mix(in oklab, var(--blue) 8%, var(--color-bg-tertiary));
}
.row-actions button.focus-btn:hover {
background: var(--blue);
color: white;
border-color: var(--blue);
}
.placement-row .primary,
.placement-row .ghost {
appearance: none;
font: inherit;
font-size: 0.8rem;
padding: 0.3rem 0.8rem;
border-radius: var(--radius-pill);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.placement-row .primary {
background: var(--color-primary);
color: var(--color-text-on-primary);
border: 0;
}
.placement-row .primary:hover {
background: var(--color-primary-hover);
}
.placement-row .ghost {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.image-visibility {
flex-wrap: wrap;
font-size: 0.78rem;
color: var(--color-text-secondary);
gap: 0.4rem;
}
.vis-label {
flex-shrink: 0;
}
.segment {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
overflow: hidden;
}
.segment button {
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-secondary);
font: inherit;
font-size: 0.78rem;
padding: 0.25rem 0.65rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.segment button + button {
border-left: 1px solid var(--color-border);
}
.segment button.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.image-attach {
display: flex;
padding: 0.5rem 0.6rem 0.65rem;
cursor: pointer;
}
.image-attach.busy {
cursor: wait;
}
.image-attach input[type='file'] {
display: none;
}
.image-attach .attach-cta {
flex: 1 1 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.45rem 0.95rem;
background:
linear-gradient(
135deg,
color-mix(in oklab, var(--color-primary) 8%, transparent),
transparent 70%
),
var(--color-bg-tertiary);
border: 1px dashed color-mix(in oklab, var(--color-primary) 32%, var(--color-border));
border-radius: var(--radius-pill);
color: var(--color-text-secondary);
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.01em;
transition:
background var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.attach-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.55rem;
height: 1.55rem;
border-radius: 50%;
background: color-mix(in oklab, var(--color-primary) 14%, var(--color-bg-elevated));
color: var(--color-primary);
flex-shrink: 0;
transition:
background var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast);
}
.image-attach:hover:not(.busy) .attach-cta {
background: var(--color-primary);
border-color: var(--color-primary);
border-style: solid;
color: var(--color-text-on-primary);
box-shadow: 0 0 0 1px var(--color-primary),
0 0.5em 1.2em -0.5em color-mix(in oklab, var(--color-primary) 60%, transparent);
transform: translateY(-1px);
}
.image-attach:hover:not(.busy) .attach-icon {
background: color-mix(in oklab, var(--color-text-on-primary) 18%, transparent);
color: var(--color-text-on-primary);
transform: rotate(-6deg) scale(1.05);
}
.image-attach.busy .attach-cta {
opacity: 0.78;
border-style: solid;
}
.image-attach :global(.spin) {
animation: attach-spin 0.85s linear infinite;
color: var(--color-primary);
}
@keyframes attach-spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,570 @@
/**
* State for the route-builder editor.
*
* The whole store is a single $state object so any field waypoints,
* routed segments, profile automatically reactivates dependent UI
* (table rows, map markers, polyline). Draft state is mirrored to
* `localStorage` so accidental tab close doesn't lose work.
*/
import { browser } from '$app/environment';
import { parseGpx, parseGpxStages, parseGpxImageRefs } from '$lib/gpx';
export type RoutingProfile = 'hiking-mountain' | 'trekking' | 'road';
export type ImageVisibility = 'public' | 'private';
export type Waypoint = {
id: string;
lat: number;
lng: number;
altitude?: number;
timestamp?: number | null;
thumbnail?: string; // optional base64 preview for marker badge + table row
/** First 8 hex chars of the source image's sha256 content hash. Matches
* the same scheme used by the build script's output filenames so the
* build can re-attach the image to this user-corrected position. */
imageHash?: string;
/** Whether the image should be visible to anonymous viewers. Both values
* embed the image in the GPX export private images are simply hidden
* from the public map unless the viewer is logged in. Defaults to
* `'public'`; only meaningful when `imageHash` is set. */
imageVisibility?: ImageVisibility;
/** When true, this waypoint represents an image with a known timestamp
* but unknown location the user still needs to drop it on the map.
* Lat/lng are placeholders (0/0) and the waypoint is hidden from the map
* and excluded from GPX export until placed. */
unplaced?: boolean;
/** When set, this (placed) waypoint begins a new stage of a multi-day
* hike, with this string as the stage name. The first placed waypoint
* always begins stage 1 implicitly; setting `stageStart` on it just names
* that stage. Exported as separate `<trk>` elements. */
stageStart?: string;
};
export type BuilderState = {
name: string;
profile: RoutingProfile;
/** When true, newly created segments are snapped to the trail network via
* the routing API. When false, new segments use a direct straight line.
* Existing (already-snapped) segments are preserved across toggle. */
autoSnap: boolean;
waypoints: Waypoint[];
/** One coordinate run per consecutive-waypoint pair (snapped or linear). */
routedSegments: Array<Array<[number, number, number?]>>; // [lng, lat, ele?]
/** Parallel record of which waypoint pair each `routedSegments[i]` was
* built for by id AND by coordinate. Both must match for the segment to
* be considered still valid, so a drag (same id, new coords) correctly
* invalidates the adjacent segments. */
segmentSources: Array<SegmentSource>;
};
export type SegmentSource = {
startId: string;
endId: string;
startLat: number;
startLng: number;
endLat: number;
endLng: number;
};
const STORAGE_KEY = 'hikes:route-builder:draft';
function loadDraft(): BuilderState {
if (!browser) return defaultState();
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultState();
const parsed = JSON.parse(raw) as BuilderState;
if (!parsed || !Array.isArray(parsed.waypoints)) return defaultState();
// Migrate older drafts that used `showImageOnMap` (boolean) instead of
// the new `imageVisibility` enum: false → private, anything else → public.
const waypoints = parsed.waypoints.map((w) => {
const legacy = w as Waypoint & { showImageOnMap?: boolean };
if (legacy.imageVisibility === undefined && legacy.showImageOnMap === false) {
return { ...legacy, imageVisibility: 'private' as const, showImageOnMap: undefined };
}
return legacy;
});
return {
name: parsed.name ?? '',
profile: parsed.profile ?? 'hiking-mountain',
autoSnap: parsed.autoSnap !== false,
waypoints,
routedSegments: Array.isArray(parsed.routedSegments) ? parsed.routedSegments : [],
segmentSources: Array.isArray(parsed.segmentSources) ? parsed.segmentSources : []
};
} catch {
return defaultState();
}
}
function defaultState(): BuilderState {
return {
name: '',
profile: 'hiking-mountain',
autoSnap: true,
waypoints: [],
routedSegments: [],
segmentSources: []
};
}
export const builder = $state<BuilderState>(loadDraft());
/**
* UI-only signals shared between the edit map and the side panels.
*
* - `fitTick`: bump to re-run `fitBounds()` on the current track. Used
* after batch insertions (image drops, GPX import) where the user
* expects the map to reframe to show every newly-added waypoint.
* - `focusId` + `focusTick`: bump to pan/zoom the map onto a specific
* waypoint AND mark it as the "current" one (drives prev/next nav and
* the highlight on the corresponding table row + marker).
*
* Not persisted pure session UI.
*/
export const mapView = $state<{
fitTick: number;
focusId: string | null;
focusTick: number;
}>({ fitTick: 0, focusId: null, focusTick: 0 });
export function requestFitBounds(): void {
mapView.fitTick++;
}
export function focusWaypoint(id: string | null): void {
mapView.focusId = id;
mapView.focusTick++;
}
/**
* Sequence number (1-based) of `wp` among placed waypoints only. Unplaced
* image entries return `null`. Single source of truth so the table badge,
* the map marker number, and the GPX export all agree on what "Wegpunkt 3"
* means.
*/
export function placedSequence(wpId: string): number | null {
let n = 0;
for (const w of builder.waypoints) {
if (w.unplaced) continue;
n++;
if (w.id === wpId) return n;
}
return null;
}
// ---------------------------------------------------------------------------
// Stages (multi-day hikes). A new stage begins at the first placed waypoint
// and at any placed waypoint carrying `stageStart`. Each stage exports as its
// own named <trk>.
// ---------------------------------------------------------------------------
/** Placed waypoints split into stages, in placed-index ranges. */
export function deriveStageGroups(): { name: string; startIdx: number; endIdx: number }[] {
const placed = builder.waypoints.filter((w) => !w.unplaced);
const groups: { name: string; startIdx: number; endIdx: number }[] = [];
for (let i = 0; i < placed.length; i++) {
if (groups.length === 0 || placed[i].stageStart !== undefined) {
groups.push({
name: placed[i].stageStart || `Etappe ${groups.length + 1}`,
startIdx: i,
endIdx: i
});
} else {
groups[groups.length - 1].endIdx = i;
}
}
return groups;
}
/** Toggle whether a placed waypoint begins a new stage. The route start can't
* be a break (it always begins stage 1). */
export function toggleStageBreak(wpId: string): void {
const placed = builder.waypoints.filter((w) => !w.unplaced);
if (placed.length === 0 || placed[0].id === wpId) return;
const wp = builder.waypoints.find((w) => w.id === wpId);
if (!wp) return;
if (wp.stageStart !== undefined) {
delete wp.stageStart;
} else {
const idxInPlaced = placed.findIndex((w) => w.id === wpId);
let n = 0;
for (let i = 0; i <= idxInPlaced; i++) {
if (i === 0 || placed[i].stageStart !== undefined || placed[i].id === wpId) n++;
}
wp.stageStart = `Etappe ${n}`;
}
scheduleSave();
}
/** Name (or rename) the stage that begins at this waypoint. Setting it on the
* first placed waypoint names stage 1 without creating an extra break. */
export function renameStage(firstWpId: string, name: string): void {
const wp = builder.waypoints.find((w) => w.id === firstWpId);
if (!wp) return;
wp.stageStart = name;
scheduleSave();
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
export function scheduleSave(): void {
if (!browser) return;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(builder));
} catch {
/* localStorage may be unavailable in private mode */
}
}, 300);
}
export function clearDraft(): void {
builder.name = '';
builder.profile = 'hiking-mountain';
builder.autoSnap = true;
builder.waypoints.splice(0, builder.waypoints.length);
builder.routedSegments.splice(0, builder.routedSegments.length);
builder.segmentSources.splice(0, builder.segmentSources.length);
if (browser) {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch { /* ignored */ }
}
}
export function nextWaypointId(): string {
return Math.random().toString(36).slice(2, 10);
}
/**
* Insert `wp` into `builder.waypoints` so that timestamped waypoints stay in
* chronological order. Waypoints without a timestamp (map-click clicks,
* draft scribbles) act as transparent neighbours they don't affect sorting.
* Without a timestamp on the new waypoint, falls back to a plain append.
*/
export function insertWaypointChronologically(wp: Waypoint): void {
if (typeof wp.timestamp !== 'number') {
builder.waypoints.push(wp);
scheduleSave();
return;
}
const t = wp.timestamp;
let insertIdx = builder.waypoints.length;
for (let i = 0; i < builder.waypoints.length; i++) {
const other = builder.waypoints[i].timestamp;
if (typeof other === 'number' && other > t) {
insertIdx = i;
break;
}
}
builder.waypoints.splice(insertIdx, 0, wp);
scheduleSave();
}
function makeSource(a: Waypoint, b: Waypoint): SegmentSource {
return {
startId: a.id,
endId: b.id,
startLat: a.lat,
startLng: a.lng,
endLat: b.lat,
endLng: b.lng
};
}
function sourcesMatch(s: SegmentSource, a: Waypoint, b: Waypoint): boolean {
return (
s.startId === a.id &&
s.endId === b.id &&
s.startLat === a.lat &&
s.startLng === a.lng &&
s.endLat === b.lat &&
s.endLng === b.lng
);
}
export function setRoutedSegments(segments: Array<Array<[number, number, number?]>>): void {
builder.routedSegments.splice(0, builder.routedSegments.length, ...segments);
const sources: SegmentSource[] = [];
for (let i = 0; i < builder.waypoints.length - 1 && i < segments.length; i++) {
sources.push(makeSource(builder.waypoints[i], builder.waypoints[i + 1]));
}
builder.segmentSources.splice(0, builder.segmentSources.length, ...sources);
}
/**
* Walk the current waypoint pairs and rebuild `routedSegments` so it aligns
* 1:1 with consecutive waypoint pairs. A segment is preserved verbatim only
* when both endpoints match (same id AND same lat/lng) a waypoint drag
* keeps the id but changes coords, which is exactly when the snapped geometry
* goes stale. Stale pairs are replaced with a straight two-point linear
* placeholder; if autoSnap is on, the page's snapToRoute call will overwrite
* them shortly after.
*/
export function reconcileSegments(): void {
const newSegs: Array<Array<[number, number, number?]>> = [];
const newSources: SegmentSource[] = [];
// Walk only placed waypoints — unplaced ones (image without location) sit
// in the table but don't participate in the track until the user drops
// them on the map.
const placed: Waypoint[] = [];
for (const w of builder.waypoints) {
if (!w.unplaced) placed.push(w);
}
for (let i = 0; i < placed.length - 1; i++) {
const a = placed[i];
const b = placed[i + 1];
const oldIdx = builder.segmentSources.findIndex((s) => sourcesMatch(s, a, b));
if (oldIdx >= 0 && builder.routedSegments[oldIdx]) {
newSegs.push(builder.routedSegments[oldIdx]);
newSources.push(builder.segmentSources[oldIdx]);
} else {
newSegs.push([
[a.lng, a.lat, a.altitude],
[b.lng, b.lat, b.altitude]
]);
newSources.push(makeSource(a, b));
}
}
builder.routedSegments.splice(0, builder.routedSegments.length, ...newSegs);
builder.segmentSources.splice(0, builder.segmentSources.length, ...newSources);
}
/** Haversine distance in metres between two `[lng, lat]` points.
* Inline so this module can stay client-only (the server helpers live in
* `$lib/server/hikesRouting.ts` and aren't importable here). */
function haversineMeters(lng1: number, lat1: number, lng2: number, lat2: number): number {
const R = 6_371_000;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * Math.PI) / 180;
const sinLat = Math.sin(dLat / 2);
const sinLng = Math.sin(dLng / 2);
const h =
sinLat * sinLat +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
sinLng * sinLng;
return 2 * R * Math.asin(Math.sqrt(h));
}
/**
* Expand every 2-point linear segment into evenly-spaced intermediate
* points so an elevation enrichment pass can capture the terrain profile
* between the two waypoints. Snapped segments (already many points from
* BRouter/OSRM) are left alone.
*
* `spacingM` defaults to 25 m matches the coarsest Swisstopo DTM that
* we sample against; finer spacing would just sample the same elevation
* value twice. Very short segments (< 30 m) skip densification: the two
* endpoints already capture every meaningful elevation step within
* Swisstopo's DTM resolution at that distance.
*
* Returns `true` when at least one segment was densified (caller can use
* this to decide whether to fire a fresh elevation request).
*/
export function densifyLinearSegments(spacingM = 25): boolean {
let densifiedAny = false;
for (let i = 0; i < builder.routedSegments.length; i++) {
const seg = builder.routedSegments[i];
if (seg.length !== 2) continue; // already snapped or already densified
const [lngA, latA, altA] = seg[0];
const [lngB, latB, altB] = seg[1];
const dist = haversineMeters(lngA, latA, lngB, latB);
if (dist < 30) continue;
// At least 4 sub-segments so even a 30-m linear sample gets a usable
// elevation profile; longer segments scale up to keep ~25 m spacing.
const n = Math.max(4, Math.ceil(dist / spacingM));
const out: Array<[number, number, number?]> = new Array(n + 1);
for (let j = 0; j <= n; j++) {
const f = j / n;
// Endpoints keep whatever altitude the caller supplied (typically
// `undefined` here — enrichment fills both ends + everything between);
// intermediates are seeded as `undefined` so the enrichment step
// knows to fill them.
const alt = j === 0 ? altA : j === n ? altB : undefined;
out[j] = [lngA + (lngB - lngA) * f, latA + (latB - latA) * f, alt];
}
builder.routedSegments[i] = out;
densifiedAny = true;
}
return densifiedAny;
}
export function setElevations(elevations: (number | null)[]): void {
// elevations are aligned with the flattened routedSegments points; fold them
// back into the per-segment arrays.
let idx = 0;
for (const seg of builder.routedSegments) {
for (let i = 0; i < seg.length; i++) {
const e = elevations[idx++];
if (typeof e === 'number') {
seg[i] = [seg[i][0], seg[i][1], e];
}
}
}
}
// ---------------------------------------------------------------------------
// GPX import — restores the builder state from a previously-exported GPX so
// the user can iterate on an existing route (add a waypoint, retag an
// image, fix a turn) without losing the densified track or photo anchors.
// ---------------------------------------------------------------------------
export type ImportGpxResult =
| { ok: true; trackName: string | null; waypointCount: number; imageCount: number }
| { ok: false; error: string };
/** Coordinate equality with a small tolerance float round-trips through
* the GPX writer can shift the 7th decimal. 1e-5° 1 m, well below the
* spacing of any meaningful pair of anchors on a hike. */
function coordsClose(aLat: number, aLng: number, bLat: number, bLng: number): boolean {
return Math.abs(aLat - bLat) < 1e-5 && Math.abs(aLng - bLng) < 1e-5;
}
/**
* Reconstruct the builder state from a GPX XML string.
*
* Strategy: the exported GPX interleaves user-anchor waypoints with
* densified/snapped intermediate trkpts in a single `<trkseg>`. We don't
* try to round-trip "manual waypoints" vs "intermediates" perfectly
* instead we recover the *image* anchors (matched against `<wpt>` entries
* by coordinate), plus the very first and last trkpts (start + end), and
* rebuild routedSegments from the trkpts that fall between each adjacent
* anchor pair. Result is an editable route where every photo waypoint is
* a draggable handle and the geometry between handles is preserved
* verbatim no re-routing required.
*
* Replaces the existing draft. Caller should confirm with the user if the
* builder is non-empty.
*/
export function importGpx(xml: string): ImportGpxResult {
const trk = parseGpx(xml);
if (trk.length < 2) {
return { ok: false, error: 'GPX enthält keinen verwertbaren Track (mind. zwei trkpt nötig).' };
}
// Stage boundaries: a multi-<trk> GPX is a multi-day route. Map each
// stage's first flat-track index to its name so we can re-mark the
// corresponding waypoint as a stage start.
const gpxStages = parseGpxStages(xml);
const multiStage = gpxStages.length > 1;
const stageNameAt = new Map<number, string>();
{
let off = 0;
for (let k = 0; k < gpxStages.length; k++) {
stageNameAt.set(off, gpxStages[k].name ?? `Etappe ${k + 1}`);
off += gpxStages[k].points.length;
}
}
const imageRefs = parseGpxImageRefs(xml);
const imageList = Object.values(imageRefs);
// Optional <name> on the track or top-level metadata.
const nameMatch =
xml.match(/<trk>[\s\S]*?<name>([^<]+)<\/name>[\s\S]*?<\/trk>/i) ??
xml.match(/<metadata>[\s\S]*?<name>([^<]+)<\/name>[\s\S]*?<\/metadata>/i);
const trackName = nameMatch ? nameMatch[1].trim() : null;
// Map each image waypoint to its first matching trkpt index. Order the
// image anchors by that index so they slot into the builder in
// traversal order, not GPX-declaration order.
type ImageAnchor = {
trkIdx: number;
hash: string;
visibility: 'public' | 'private';
lat: number;
lng: number;
altitude?: number;
timestamp?: number;
};
const imageAnchors: ImageAnchor[] = [];
for (const ref of imageList) {
let bestIdx = -1;
for (let i = 0; i < trk.length; i++) {
if (coordsClose(trk[i].lat, trk[i].lng, ref.lat, ref.lng)) {
bestIdx = i;
break;
}
}
if (bestIdx < 0) continue; // wpt position doesn't match any trkpt — skip
imageAnchors.push({
trkIdx: bestIdx,
hash: ref.hash,
visibility: ref.visibility === 'private' ? 'private' : 'public',
lat: ref.lat,
lng: ref.lng,
altitude: ref.altitude,
timestamp: ref.timestamp
});
}
imageAnchors.sort((a, b) => a.trkIdx - b.trkIdx);
// Build the set of anchor trkpt indices: first, last, all image anchors,
// plus every stage boundary so multi-day breaks survive the round-trip.
const anchorIndices = new Set<number>([0, trk.length - 1]);
for (const ia of imageAnchors) anchorIndices.add(ia.trkIdx);
if (multiStage) for (const idx of stageNameAt.keys()) anchorIndices.add(idx);
const sortedAnchorIdx = [...anchorIndices].sort((a, b) => a - b);
// Assemble waypoints in traversal order.
const newWaypoints: Waypoint[] = sortedAnchorIdx.map((i) => {
const t = trk[i];
const ia = imageAnchors.find((a) => a.trkIdx === i);
const wp: Waypoint = {
id: nextWaypointId(),
lat: t.lat,
lng: t.lng,
altitude: typeof t.altitude === 'number' ? t.altitude : ia?.altitude,
timestamp: t.timestamp ?? ia?.timestamp ?? null
};
if (ia) {
wp.imageHash = ia.hash;
wp.imageVisibility = ia.visibility;
}
// Re-mark stage starts (skip index 0 — the route start is stage 1
// implicitly; naming it is harmless but unnecessary).
if (multiStage && i > 0 && stageNameAt.has(i)) {
wp.stageStart = stageNameAt.get(i);
}
return wp;
});
// Reconstruct routedSegments from the trkpts between consecutive anchors.
// Each segment is `[lng, lat, ele?][]` and spans anchor[i] .. anchor[i+1]
// inclusive — the GPX writer's reverse operation.
const newSegments: Array<Array<[number, number, number?]>> = [];
for (let i = 0; i < sortedAnchorIdx.length - 1; i++) {
const start = sortedAnchorIdx[i];
const end = sortedAnchorIdx[i + 1];
const seg: Array<[number, number, number?]> = [];
for (let j = start; j <= end; j++) {
const t = trk[j];
seg.push([t.lng, t.lat, typeof t.altitude === 'number' ? t.altitude : undefined]);
}
newSegments.push(seg);
}
const newSources: SegmentSource[] = [];
for (let i = 0; i < newWaypoints.length - 1; i++) {
newSources.push(makeSource(newWaypoints[i], newWaypoints[i + 1]));
}
// Atomic swap.
builder.name = trackName ?? builder.name ?? '';
// Disable auto-snap so the imported densified/snapped geometry isn't
// immediately overwritten by a routing API call.
builder.autoSnap = false;
builder.waypoints.splice(0, builder.waypoints.length, ...newWaypoints);
builder.routedSegments.splice(0, builder.routedSegments.length, ...newSegments);
builder.segmentSources.splice(0, builder.segmentSources.length, ...newSources);
scheduleSave();
return {
ok: true,
trackName,
waypointCount: newWaypoints.length,
imageCount: imageAnchors.length
};
}
@@ -0,0 +1,16 @@
/**
* Single-point Swisstopo elevation lookup for the route-builder.
* Image GPS altitude (EXIF GPSAltitude) is unreliable and causes spikes in the
* elevation profile, so all waypoints including image-derived ones should
* source their altitude from the terrain model instead.
*/
export async function fetchElevationAt(lat: number, lng: number): Promise<number | null> {
try {
const res = await fetch(`/api/hikes/route-builder/elevation?lat=${lat}&lng=${lng}`);
if (!res.ok) return null;
const { elevation } = (await res.json()) as { elevation: number | null };
return typeof elevation === 'number' ? elevation : null;
} catch {
return null;
}
}
@@ -0,0 +1,33 @@
/**
* In-memory cache of full-resolution image Blob URLs keyed by waypoint id.
*
* Storing the original File as a base64 data URL would blow past the
* localStorage quota almost immediately, so the persisted draft only carries
* a 360w WebP thumbnail. The full-resolution preview lives here only for the
* lifetime of the page on reload, callers fall back to the thumbnail.
*
* Backed by a Svelte 5 `$state` proxy so the table re-renders the moment a
* fresh Blob URL is registered (e.g. after image upload or re-attach).
*/
import { browser } from '$app/environment';
const urls = $state<Record<string, string>>({});
export function setFullImage(waypointId: string, file: Blob): void {
if (!browser) return;
const old = urls[waypointId];
if (old) URL.revokeObjectURL(old);
urls[waypointId] = URL.createObjectURL(file);
}
export function getFullImageUrl(waypointId: string): string | undefined {
return urls[waypointId];
}
export function dropFullImage(waypointId: string): void {
if (!browser) return;
const url = urls[waypointId];
if (url) URL.revokeObjectURL(url);
delete urls[waypointId];
}
@@ -0,0 +1,35 @@
/**
* Render a WebP data-URL preview of an image file using the browser's canvas.
* 360px wide large enough to serve as a full-width preview in the waypoint
* table while still being a sane base64 payload (~30 KB) for localStorage.
* The marker badge on the map (56×56) downsamples it cleanly.
*/
const PREVIEW_WIDTH = 360;
export async function readThumbnail(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
const w = Math.min(PREVIEW_WIDTH, img.width || PREVIEW_WIDTH);
const ratio = img.width / img.height || 1;
const h = Math.round(w / ratio);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('canvas 2d unavailable'));
return;
}
ctx.drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/webp', 0.7));
};
img.onerror = () => reject(new Error('Konnte Bild nicht laden'));
img.src = reader.result as string;
};
reader.onerror = () => reject(new Error('Konnte Datei nicht lesen'));
reader.readAsDataURL(file);
});
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Module-scoped registry of "scroll anchors" DOM elements rendered by
* inline `<HikeImage>` components whose viewport positions are sampled on
* every scroll frame to compute a continuous trail-position indicator.
*
* Each anchor carries:
* - `element` the DOM node we read `getBoundingClientRect()` from.
* - `trackIdx` the index in the GPX track points array nearest to the
* image's timestamp. The "current trail position" is interpolated between
* adjacent anchors' `trackIdx` based on scroll progress.
* - `visibleIdx` index in the visibility-filtered ImagePoints. Used to
* drive the focused store (strip highlighting) when the nearest-image
* changes.
*
* The registry is a singleton because there's only ever one hike detail
* page open at a time, and a Svelte context would otherwise force every
* read site (the page's scroll listener) to be inside the component tree.
*/
export interface ScrollAnchor {
element: HTMLElement;
trackIdx: number;
visibleIdx: number;
}
const anchors = new Set<ScrollAnchor>();
export function addScrollAnchor(a: ScrollAnchor): () => void {
anchors.add(a);
return () => {
anchors.delete(a);
};
}
export function listScrollAnchors(): ScrollAnchor[] {
return Array.from(anchors);
}
@@ -0,0 +1,19 @@
/**
* Active-stage selection for a multi-day hike detail page.
*
* `active` is the index into the hike's `stages[]`, or `null` for the
* "Alle Etappen" (whole route) view. The stage nav writes it; the map,
* elevation profile, metrics row and photo strip read it to scope themselves
* to one stage. A shared rune (like hoverStore / focusedImageStore) avoids
* prop-drilling through the two map instances.
*/
export const stage = $state<{ active: number | null }>({ active: null });
export function setActiveStage(index: number | null): void {
stage.active = index;
}
export function clearActiveStage(): void {
stage.active = null;
}
-434
View File
@@ -1,434 +0,0 @@
<script lang="ts">
import Cross from '$lib/assets/icons/Cross.svelte'
import { toast } from '$lib/js/toast.svelte'
import "$lib/css/shake.css"
import "$lib/css/icon.css"
import { onMount } from 'svelte'
let {
card_data = $bindable(),
image_preview_url = $bindable(''),
selected_image_file = $bindable<File | null>(null),
short_name = ''
}: {
card_data: any,
image_preview_url: string,
selected_image_file: File | null,
short_name: string
} = $props();
// Constants for validation
const ALLOWED_MIME_TYPES = ['image/webp', 'image/jpeg', 'image/jpg', 'image/png'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
// Handle file selection via onchange event
function handleFileSelect(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
// Validate MIME type
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
toast.error('Invalid file type. Please upload a JPEG, PNG, or WebP image.');
input.value = '';
return;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
toast.error(`File too large. Maximum size is 5MB. Your file is ${(file.size / 1024 / 1024).toFixed(2)}MB.`);
input.value = '';
return;
}
// Clean up old preview URL if exists
if (image_preview_url && image_preview_url.startsWith('blob:')) {
URL.revokeObjectURL(image_preview_url);
}
// Create preview and store file
image_preview_url = URL.createObjectURL(file);
selected_image_file = file;
}
// Check if initial image_preview_url redirects to placeholder
onMount(() => {
if (image_preview_url && !image_preview_url.startsWith('blob:')) {
const img = new Image();
img.onload = () => {
// Check if this is the placeholder image (150x150)
if (img.naturalWidth === 150 && img.naturalHeight === 150) {
image_preview_url = ""
}
};
img.onerror = () => {
image_preview_url = ""
};
img.src = image_preview_url;
}
});
// Initialize tags if needed
if (!card_data.tags) {
card_data.tags = []
}
// Tag management
let new_tag = $state("");
// Reference to file input for clearing
let fileInput: HTMLInputElement;
function remove_selected_images() {
if (image_preview_url && image_preview_url.startsWith('blob:')) {
URL.revokeObjectURL(image_preview_url);
}
image_preview_url = "";
selected_image_file = null;
// Reset the file input
if (fileInput) {
fileInput.value = '';
}
}
function add_to_tags() {
if (new_tag && !card_data.tags.includes(new_tag)) {
card_data.tags = [...card_data.tags, new_tag];
}
new_tag = "";
}
function remove_from_tags(tag: string) {
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
}
function add_on_enter(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
add_to_tags();
}
}
function remove_on_enter(event: KeyboardEvent, tag: string) {
if (event.key === 'Enter') {
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
}
}
</script>
<style>
.card{
position: relative;
margin-inline: auto;
--card-width: 300px;
text-decoration: none;
position: relative;
box-sizing: border-box;
width: var(--card-width);
aspect-ratio: 4/7;
border-radius: var(--radius-card);
background-size: contain;
display: flex;
flex-direction: column;
justify-content: end;
transition: var(--transition-normal);
background-color: var(--blue);
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.3);
z-index: 0;
}
.img_label{
position :absolute;
z-index: 1;
top: 0;
width: 100%;
height: 100%;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
border-radius: 20px 20px 0 0 ;
transition: var(--transition-normal);
}
.img_label_wrapper:hover{
background-color: var(--red);
box-shadow: 0 2em 1em 0.5em rgba(0,0,0,0.3);
transform:scale(1.02, 1.02);
}
.img_label_wrapper{
position: absolute;
height: 50%;
width: 100%;
top:0;
left: 0;
border-radius: 20px 20px 0 0;
transition: var(--transition-normal);
}
.img_label_wrapper:hover .delete{
opacity: 100%;
}
.img_label svg{
width: 100px;
height: 100px;
fill: white;
transition: var(--transition-normal);
}
.delete{
cursor: pointer;
all: unset;
position: absolute;
top:2rem;
left: 2rem;
opacity: 0%;
z-index: 4;
transition: var(--transition-normal);
}
.delete:hover{
transform: scale(1.2, 1.2);
}
.upload{
z-index: 1;
}
.img_label:hover .upload{
transform: scale(1.2, 1.2);
z-index: 10;
}
#img_picker{
display: none;
width: 300px;
height: 300px;
position:absolute;
}
input{
all: unset;
}
input::placeholder{
all:unset;
}
.card .icon{
z-index: 3;
box-sizing: border-box;
text-decoration: unset;
text-align:center;
width: 2.6rem;
aspect-ratio: 1/1;
transition: var(--transition-fast);
position: absolute;
font-size: 1.5rem;
top:-0.5em;
right:-0.5em;
padding: 0.25em;
background-color: var(--nord6);
border-radius: var(--radius-pill);
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.6);
}
.card .icon:hover,
.card .icon:focus-visible
{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform:scale(1.2, 1.2)
}
.card:hover,
.card:focus-within{
transform: scale(1.02,1.02);
box-shadow: 0.2em 0.2em 2em 1em rgba(0, 0, 0, 0.3);
}
.card img{
height: 50%;
object-fit: cover;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.card .title {
position: relative;
box-sizing: border-box;
padding-top: 0.5em;
height: 50%;
width: 100% ;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: var(--transition-fast);
}
.card .name{
all: unset;
width:100%;
font-size: 2em;
color: white;
padding-inline: 0.5em;
padding-block: 0.2em;
}
.card .name:hover{
color:var(--nord0);
}
.card .description{
box-sizing:border-box;
border: 2px solid var(--nord5);
border-radius: 30px;
padding-inline: 1em;
padding-block: 0.5em;
margin-inline: 1em;
margin-top: 0;
color: var(--nord4);
width: calc(300px - 2em); /*??*/
}
.card .description:hover{
color: var(--nord0);
border: 2px solid var(--nord0);
}
.card .tags{
display: flex;
flex-wrap: wrap-reverse;
overflow: hidden;
column-gap: 0.25em;
padding-inline: 0.5em;
padding-top: 0.25em;
margin-bottom:0.5em;
flex-grow: 0;
}
.card .tag{
cursor: pointer;
text-decoration: unset;
background-color: var(--nord4);
color: var(--nord0);
border-radius: 100px;
padding-inline: 1em;
line-height: 1.5em;
margin-bottom: 0.5em;
transition: var(--transition-fast);
box-shadow: 0.2em 0.2em 0.2em 0.05em rgba(0, 0, 0, 0.3);
}
.card .tag:hover,
.card .tag:focus-visible,
.card .tag:focus-within
{
transform: scale(1.04, 1.04);
background-color: var(--nord8);
box-shadow: 0.2em 0.2em 0.2em 0.1em rgba(0, 0, 0, 0.3);
}
.card .title .category{
z-index: 2;
position: absolute;
box-shadow: 0em 0em 1em 0.1em rgba(0, 0, 0, 0.6);
text-decoration: none;
color: var(--nord6);
font-size: 1.5rem;
top: -0.8em;
left: -0.5em;
width: 10rem;
background-color: var(--nord0);
padding-inline: 1em;
border-radius: var(--radius-pill);
transition: var(--transition-fast);
}
.card .title .category:hover,
.card .title .category:focus-within
{
box-shadow: -0.2em 0.2em 1em 0.1em rgba(0, 0, 0, 0.6);
background-color: var(--nord3);
transform: scale(1.05, 1.05)
}
.card:hover .icon,
.card:focus-visible .icon
{
animation: shake 0.6s
}
@keyframes shake{
0%{
transform: rotate(0)
scale(1,1);
}
25%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(30deg)
scale(1.2,1.2)
;
}
50%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(-30deg)
scale(1.2,1.2);
}
74%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(30deg)
scale(1.2, 1.2);
}
100%{
transform: rotate(0)
scale(1,1);
}
}
.input_wrapper{
position: relative;
padding-left: 3rem;
padding-left: 40rem;
}
.input_wrapper > input{
margin-left: 1ch;
}
.input{
position:absolute;
top: -.1ch;
left: 0.6ch;
font-size: 1.6rem;
}
.tag_input{
width: 12ch;
}
</style>
<div class=card>
<input class=icon placeholder=🥫 bind:value={card_data.icon}/>
{#if image_preview_url}
<!-- svelte-ignore a11y_missing_attribute -->
<img src={image_preview_url} class=img_preview width=300px height=300px />
{/if}
<div class=img_label_wrapper>
{#if image_preview_url}
<button class=delete onclick={remove_selected_images}>
<Cross fill=white style="width:2rem;height:2rem;"></Cross>
</button>
{/if}
<label class=img_label for=img_picker>
<svg class="upload over_img" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></svg>
</label>
</div>
<input type="file" id="img_picker" accept="image/webp,image/jpeg,image/jpg,image/png" onchange={handleFileSelect} bind:this={fileInput}>
<div class=title>
<input class=category placeholder=Kategorie... bind:value={card_data.category}/>
<div>
<input class=name placeholder=Name... bind:value={card_data.name}/>
<p contenteditable class=description placeholder=Kurzbeschreibung... bind:innerText={card_data.description}></p>
</div>
<div class=tags>
{#each card_data.tags as tag (tag)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="tag" role="button" tabindex="0" onkeydown={(event) => remove_on_enter(event, tag)} onclick={() => remove_from_tags(tag)} aria-label="Tag {tag} entfernen">{tag}</div>
{/each}
<div class="tag input_wrapper"><span class=input>+</span><input class="tag_input" type="text" onkeydown={add_on_enter} onfocusout={add_to_tags} size="1" bind:value={new_tag} placeholder=Stichwort...></div>
</div>
</div>
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import Cross from '$lib/assets/icons/Cross.svelte';
import ImageEditor from '$lib/components/recipes/ImageEditor.svelte';
import { toast } from '$lib/js/toast.svelte';
import { onMount, type Snippet } from 'svelte';
@@ -53,9 +54,34 @@
input.value = '';
return;
}
openEditor(file);
input.value = '';
}
// Photo editor (crop / scale / webp quality) state
let editorFile = $state<File | null>(null);
let editorOpen = $state(false);
function openEditor(file: File) {
editorFile = file;
editorOpen = true;
}
function closeEditor() {
editorOpen = false;
editorFile = null;
if (fileInput) fileInput.value = '';
}
function handleEditorApply(file: File, url: string) {
if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url);
image_preview_url = URL.createObjectURL(file);
selected_image_file = file;
image_preview_url = url;
closeEditor();
}
function editCurrentImage() {
if (selected_image_file) openEditor(selected_image_file);
}
function clearSelectedImage() {
@@ -129,15 +155,30 @@
</div>
</button>
{#if selected_image_file}
<div class="img-controls">
<button
type="button"
class="clear-img"
class="img-btn"
onclick={editCurrentImage}
title="Bild bearbeiten"
aria-label="Bild bearbeiten"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true">
<path
d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"
/>
</svg>
</button>
<button
type="button"
class="img-btn danger"
onclick={clearSelectedImage}
title="Auswahl verwerfen"
aria-label="Auswahl verwerfen"
>
<Cross fill="white" width="1.25rem" height="1.25rem" />
<Cross fill="white" width="1.15rem" height="1.15rem" />
</button>
</div>
{/if}
<input
bind:this={fileInput}
@@ -215,6 +256,10 @@
</div>
</section>
{#if editorOpen && editorFile}
<ImageEditor file={editorFile} onApply={handleEditorApply} onCancel={closeEditor} />
{/if}
<style>
.section {
--scale: 0.3;
@@ -312,10 +357,18 @@
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.clear-img {
/* Edit / remove controls — top-right of the image, offset below the fixed
site header (height 3rem, top max(12px, safe-area+4px)) so the nav never
obstructs them. */
.img-controls {
position: absolute;
top: calc(1rem + env(safe-area-inset-top, 0px));
top: calc(max(12px, env(safe-area-inset-top, 0px) + 4px) + 3rem + 1rem);
right: 1rem;
display: flex;
gap: 0.5rem;
z-index: 5;
}
.img-btn {
background: rgba(0, 0, 0, 0.55);
border: none;
width: 2.5rem;
@@ -324,17 +377,26 @@
display: grid;
place-items: center;
cursor: pointer;
z-index: 5;
transition:
transform 150ms ease,
background 150ms ease;
backdrop-filter: blur(6px);
box-shadow: var(--shadow-sm);
}
.clear-img:hover,
.clear-img:focus-visible {
background: var(--red);
.img-btn svg {
width: 1.15rem;
height: 1.15rem;
fill: white;
}
.img-btn:hover,
.img-btn:focus-visible {
background: var(--color-primary);
transform: scale(1.08);
}
.img-btn.danger:hover,
.img-btn.danger:focus-visible {
background: var(--red);
}
.file-input {
position: absolute;
@@ -0,0 +1,797 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
loadBitmap,
renderToBlob,
fitWithin,
blobToFile,
formatBytes,
type CropRect
} from '$lib/js/imageEdit';
type Props = {
file: File;
shortName?: string;
onApply: (file: File, url: string) => void;
onCancel: () => void;
};
let { file, shortName = '', onApply, onCancel }: Props = $props();
const MIN_CROP = 24; // minimum crop edge, source px
const RATIOS = [
{ key: 'free', label: 'Frei' },
{ key: 'orig', label: 'Original' },
{ key: '1:1', label: '1:1', value: 1 },
{ key: '4:3', label: '4:3', value: 4 / 3 },
{ key: '3:2', label: '3:2', value: 3 / 2 },
{ key: '16:9', label: '16:9', value: 16 / 9 }
] as const;
const RES_PRESETS = [1000, 1500, 2000, 0]; // 0 = Original
let bitmap = $state<ImageBitmap | null>(null);
let imgW = $state(0);
let imgH = $state(0);
let loadError = $state('');
let crop = $state<CropRect>({ x: 0, y: 0, w: 0, h: 0 });
let ratioMode = $state<string>('free');
let maxRes = $state(2000);
let quality = $state(92);
// Live-encode output
let outBlob = $state<Blob | null>(null);
let outUrl = $state('');
let outW = $state(0);
let outH = $state(0);
let encoding = $state(false);
// Stage measurement
let stageW = $state(0);
let stageH = $state(0);
let stageCanvas = $state<HTMLCanvasElement | null>(null);
const activeRatio = $derived.by(() => {
const r = RATIOS.find((x) => x.key === ratioMode);
if (!r) return null;
if (r.key === 'orig') return imgH ? imgW / imgH : null;
return 'value' in r ? r.value : null;
});
// Fit the source image into the available stage area (display pixels).
const displayScale = $derived.by(() => {
if (!imgW || !imgH || !stageW || !stageH) return 1;
const availW = Math.max(1, stageW - 24);
const availH = Math.max(1, stageH - 24);
return Math.min(availW / imgW, availH / imgH);
});
const dispW = $derived(Math.round(imgW * displayScale));
const dispH = $derived(Math.round(imgH * displayScale));
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
onMount(() => {
let cancelled = false;
(async () => {
try {
const bm = await loadBitmap(file);
if (cancelled) {
bm.close?.();
return;
}
bitmap = bm;
imgW = bm.width;
imgH = bm.height;
crop = { x: 0, y: 0, w: bm.width, h: bm.height };
} catch {
loadError = 'Bild konnte nicht geladen werden.';
}
})();
return () => {
cancelled = true;
};
});
// Draw the source onto the display canvas whenever it or the layout changes.
$effect(() => {
const cv = stageCanvas;
const bm = bitmap;
const w = dispW;
const h = dispH;
if (!cv || !bm || w <= 0 || h <= 0) return;
cv.width = w;
cv.height = h;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.clearRect(0, 0, w, h);
ctx.drawImage(bm, 0, 0, imgW, imgH, 0, 0, w, h);
});
// Debounced live encode — runs whenever crop / resolution / quality change.
let encodeToken = 0;
$effect(() => {
const bm = bitmap;
if (!bm) return;
const c = { ...crop };
const mr = maxRes;
const q = quality;
const token = ++encodeToken;
encoding = true;
const timer = setTimeout(async () => {
try {
const blob = await renderToBlob(bm, c, mr, q);
if (token !== encodeToken) return;
const size = fitWithin(c.w, c.h, mr);
if (outUrl) URL.revokeObjectURL(outUrl);
outBlob = blob;
outUrl = URL.createObjectURL(blob);
outW = size.w;
outH = size.h;
} catch {
/* transient encode failure — next change retries */
} finally {
if (token === encodeToken) encoding = false;
}
}, 200);
return () => clearTimeout(timer);
});
$effect(() => {
return () => {
if (outUrl) URL.revokeObjectURL(outUrl);
bitmap?.close?.();
};
});
// --- Crop drag handling ---
type Drag = { handle: string; hx: number; hy: number; px: number; py: number; start: CropRect };
let drag: Drag | null = null;
function startDrag(e: PointerEvent, handle: string, hx: number, hy: number) {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as Element).setPointerCapture(e.pointerId);
drag = { handle, hx, hy, px: e.clientX, py: e.clientY, start: { ...crop } };
}
function onPointerMove(e: PointerEvent) {
if (!drag || displayScale === 0) return;
const ddx = (e.clientX - drag.px) / displayScale;
const ddy = (e.clientY - drag.py) / displayScale;
const s = drag.start;
if (drag.handle === 'move') {
crop = {
x: clamp(s.x + ddx, 0, imgW - s.w),
y: clamp(s.y + ddy, 0, imgH - s.h),
w: s.w,
h: s.h
};
return;
}
let left = s.x;
let top = s.y;
let right = s.x + s.w;
let bottom = s.y + s.h;
if (drag.hx === 1) right = s.x + s.w + ddx;
else if (drag.hx === -1) left = s.x + ddx;
if (drag.hy === 1) bottom = s.y + s.h + ddy;
else if (drag.hy === -1) top = s.y + ddy;
const r = activeRatio;
if (r) {
if (drag.hx !== 0 && drag.hy !== 0) {
const nw = Math.max(MIN_CROP, right - left);
const nh = nw / r;
if (drag.hy === 1) bottom = top + nh;
else top = bottom - nh;
} else if (drag.hx !== 0) {
const cy = s.y + s.h / 2;
const nh = Math.max(MIN_CROP, right - left) / r;
top = cy - nh / 2;
bottom = cy + nh / 2;
} else if (drag.hy !== 0) {
const cx = s.x + s.w / 2;
const nw = Math.max(MIN_CROP, bottom - top) * r;
left = cx - nw / 2;
right = cx + nw / 2;
}
}
left = Math.max(0, left);
top = Math.max(0, top);
right = Math.min(imgW, right);
bottom = Math.min(imgH, bottom);
if (right - left < MIN_CROP) {
if (drag.hx === -1) left = right - MIN_CROP;
else right = left + MIN_CROP;
}
if (bottom - top < MIN_CROP) {
if (drag.hy === -1) top = bottom - MIN_CROP;
else bottom = top + MIN_CROP;
}
left = Math.max(0, left);
top = Math.max(0, top);
right = Math.min(imgW, right);
bottom = Math.min(imgH, bottom);
crop = { x: left, y: top, w: right - left, h: bottom - top };
}
function endDrag() {
// Pointer capture is released implicitly on pointerup.
drag = null;
}
function selectRatio(key: string) {
ratioMode = key;
const r = RATIOS.find((x) => x.key === key);
const value = r && r.key === 'orig' ? imgW / imgH : r && 'value' in r ? r.value : null;
if (!value) return; // 'free' keeps the current crop
// Fit a centred rect of this ratio inside the current crop.
const cx = crop.x + crop.w / 2;
const cy = crop.y + crop.h / 2;
let nw = crop.w;
let nh = nw / value;
if (nh > crop.h) {
nh = crop.h;
nw = nh * value;
}
nw = Math.min(nw, imgW);
nh = Math.min(nh, imgH);
crop = {
x: clamp(cx - nw / 2, 0, imgW - nw),
y: clamp(cy - nh / 2, 0, imgH - nh),
w: nw,
h: nh
};
}
function resetCrop() {
ratioMode = 'free';
crop = { x: 0, y: 0, w: imgW, h: imgH };
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
}
function apply() {
if (!outBlob || !outUrl) return;
const url = outUrl;
const f = blobToFile(outBlob, shortName);
outUrl = ''; // hand the object URL off to the caller; don't revoke it
onApply(f, url);
}
const handles = [
{ key: 'nw', hx: -1, hy: -1 },
{ key: 'n', hx: 0, hy: -1 },
{ key: 'ne', hx: 1, hy: -1 },
{ key: 'e', hx: 1, hy: 0 },
{ key: 'se', hx: 1, hy: 1 },
{ key: 's', hx: 0, hy: 1 },
{ key: 'sw', hx: -1, hy: 1 },
{ key: 'w', hx: -1, hy: 0 }
];
</script>
<svelte:window onkeydown={onKeydown} />
<div
class="backdrop"
role="dialog"
aria-modal="true"
aria-label="Bild bearbeiten"
tabindex="-1"
>
<button type="button" class="scrim" aria-label="Schliessen" onclick={onCancel}></button>
<div class="panel">
<header class="panel-head">
<h2>Bild bearbeiten</h2>
<button type="button" class="ghost" onclick={onCancel} aria-label="Abbrechen"></button>
</header>
<div class="body">
<!-- Stage -->
<div class="stage" bind:clientWidth={stageW} bind:clientHeight={stageH}>
{#if loadError}
<p class="stage-msg">{loadError}</p>
{:else if !bitmap}
<p class="stage-msg">Lade Bild…</p>
{:else}
<div class="frame" style:width="{dispW}px" style:height="{dispH}px">
<canvas bind:this={stageCanvas}></canvas>
<div
class="crop"
style:left="{crop.x * displayScale}px"
style:top="{crop.y * displayScale}px"
style:width="{crop.w * displayScale}px"
style:height="{crop.h * displayScale}px"
role="application"
aria-label="Zuschneidebereich verschieben"
onpointerdown={(e) => startDrag(e, 'move', 0, 0)}
onpointermove={onPointerMove}
onpointerup={endDrag}
onpointercancel={endDrag}
>
<span class="third v1"></span>
<span class="third v2"></span>
<span class="third h1"></span>
<span class="third h2"></span>
{#each handles as h (h.key)}
<button
type="button"
class="handle h-{h.key}"
aria-label="Ziehpunkt {h.key}"
onpointerdown={(e) => startDrag(e, h.key, h.hx, h.hy)}
></button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Controls -->
<div class="rail">
<div class="preview">
<div class="preview-img" class:busy={encoding}>
{#if outUrl}
<!-- svelte-ignore a11y_missing_attribute -->
<img src={outUrl} />
{/if}
</div>
<dl class="stats">
<div><dt>Auflösung</dt><dd>{outW || '—'} × {outH || '—'}</dd></div>
<div>
<dt>Dateigrösse</dt>
<dd class="size">{outBlob ? formatBytes(outBlob.size) : '—'}</dd>
</div>
</dl>
</div>
<fieldset class="group">
<legend>Seitenverhältnis</legend>
<div class="chips">
{#each RATIOS as r (r.key)}
<button
type="button"
class="chip"
class:active={ratioMode === r.key}
onclick={() => selectRatio(r.key)}>{r.label}</button
>
{/each}
</div>
</fieldset>
<fieldset class="group">
<legend>Max. Auflösung</legend>
<div class="chips">
{#each RES_PRESETS as p (p)}
<button
type="button"
class="chip"
class:active={maxRes === p}
onclick={() => (maxRes = p)}>{p === 0 ? 'Original' : p}</button
>
{/each}
</div>
<label class="custom">
<span>Eigene Kante</span>
<input type="number" min="0" step="50" bind:value={maxRes} />
<span class="unit">px</span>
</label>
</fieldset>
<fieldset class="group">
<legend>WebP-Qualität</legend>
<div class="quality">
<input type="range" min="1" max="100" step="1" bind:value={quality} />
<output>{quality}</output>
</div>
</fieldset>
<button type="button" class="reset" onclick={resetCrop}>Zuschnitt zurücksetzen</button>
</div>
</div>
<footer class="panel-foot">
<button type="button" class="btn ghost-btn" onclick={onCancel}>Abbrechen</button>
<button type="button" class="btn primary" disabled={!outBlob} onclick={apply}>
Übernehmen
</button>
</footer>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: clamp(0px, 2vw, 1.5rem);
}
.scrim {
all: unset;
position: absolute;
inset: 0;
background: rgba(10, 14, 20, 0.6);
backdrop-filter: blur(4px);
cursor: pointer;
}
.panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: min(1100px, 100%);
height: min(760px, 100%);
max-height: 100%;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.panel-head,
.panel-foot {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.85rem 1.25rem;
flex-shrink: 0;
}
.panel-head {
justify-content: space-between;
border-bottom: 1px solid var(--color-border);
}
.panel-head h2 {
margin: 0;
font-size: var(--text-lg, 1.2rem);
color: var(--color-text-primary);
}
.panel-foot {
justify-content: flex-end;
border-top: 1px solid var(--color-border);
}
.ghost {
all: unset;
cursor: pointer;
color: var(--color-text-secondary);
font-size: 1.1rem;
line-height: 1;
padding: 0.35rem;
border-radius: var(--radius-sm);
}
.ghost:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.body {
display: grid;
grid-template-columns: 1fr 320px;
flex: 1;
min-height: 0;
}
/* Stage */
.stage {
position: relative;
display: grid;
place-items: center;
min-height: 0;
background:
repeating-conic-gradient(var(--color-bg-secondary) 0% 25%, transparent 0% 50%) 50% / 24px 24px;
background-color: var(--color-bg-tertiary);
overflow: hidden;
}
.stage-msg {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.frame {
position: relative;
touch-action: none;
box-shadow: var(--shadow-md);
}
.frame canvas {
display: block;
width: 100%;
height: 100%;
}
.crop {
position: absolute;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
cursor: move;
touch-action: none;
}
.third {
position: absolute;
background: rgba(255, 255, 255, 0.35);
pointer-events: none;
}
.third.v1,
.third.v2 {
top: 0;
bottom: 0;
width: 1px;
}
.third.v1 {
left: 33.33%;
}
.third.v2 {
left: 66.66%;
}
.third.h1,
.third.h2 {
left: 0;
right: 0;
height: 1px;
}
.third.h1 {
top: 33.33%;
}
.third.h2 {
top: 66.66%;
}
.handle {
all: unset;
position: absolute;
width: 14px;
height: 14px;
box-sizing: border-box;
background: var(--color-primary);
border: 2px solid white;
border-radius: 50%;
cursor: pointer;
touch-action: none;
}
.h-nw {
top: -7px;
left: -7px;
cursor: nwse-resize;
}
.h-ne {
top: -7px;
right: -7px;
cursor: nesw-resize;
}
.h-se {
bottom: -7px;
right: -7px;
cursor: nwse-resize;
}
.h-sw {
bottom: -7px;
left: -7px;
cursor: nesw-resize;
}
.h-n {
top: -7px;
left: calc(50% - 7px);
cursor: ns-resize;
}
.h-s {
bottom: -7px;
left: calc(50% - 7px);
cursor: ns-resize;
}
.h-e {
right: -7px;
top: calc(50% - 7px);
cursor: ew-resize;
}
.h-w {
left: -7px;
top: calc(50% - 7px);
cursor: ew-resize;
}
/* Rail */
.rail {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: 1.1rem;
overflow-y: auto;
border-left: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.preview {
display: flex;
gap: 0.85rem;
align-items: center;
}
.preview-img {
flex-shrink: 0;
width: 96px;
height: 96px;
border-radius: var(--radius-md);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
overflow: hidden;
display: grid;
place-items: center;
transition: opacity 150ms ease;
}
.preview-img.busy {
opacity: 0.55;
}
.preview-img img {
width: 100%;
height: 100%;
object-fit: cover;
}
.stats {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stats dt {
font-size: var(--text-sm, 0.8rem);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stats dd {
margin: 0;
font-size: var(--text-md, 1rem);
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
.stats dd.size {
font-weight: 700;
color: var(--color-primary);
}
.group {
border: none;
margin: 0;
padding: 0;
min-width: 0;
}
.group legend {
padding: 0;
font-size: var(--text-sm, 0.8rem);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
all: unset;
cursor: pointer;
padding: 0.35rem 0.7rem;
border-radius: var(--radius-pill);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: var(--text-sm, 0.85rem);
transition: var(--transition-fast);
}
.chip:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.chip.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-text-on-primary);
}
.custom {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
font-size: var(--text-sm, 0.85rem);
color: var(--color-text-secondary);
}
.custom input {
width: 6ch;
padding: 0.3rem 0.5rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font: inherit;
}
.custom .unit {
color: var(--color-text-tertiary);
}
.quality {
display: flex;
align-items: center;
gap: 0.75rem;
}
.quality input[type='range'] {
flex: 1;
accent-color: var(--color-primary);
}
.quality output {
min-width: 2.5ch;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 700;
color: var(--color-text-primary);
}
.reset {
all: unset;
cursor: pointer;
text-align: center;
padding: 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: var(--text-sm, 0.85rem);
transition: var(--transition-fast);
}
.reset:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.btn {
all: unset;
cursor: pointer;
padding: 0.6rem 1.4rem;
border-radius: var(--radius-pill);
font-weight: 600;
transition: var(--transition-fast);
}
.ghost-btn {
color: var(--color-text-secondary);
}
.ghost-btn:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.primary {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.primary:hover {
background: var(--color-primary-hover);
}
.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 760px) {
.panel {
height: 100%;
border-radius: 0;
}
.body {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) auto;
}
.rail {
border-left: none;
border-top: 1px solid var(--color-border);
max-height: 45dvh;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More