Commit Graph

1041 Commits

Author SHA1 Message Date
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