Files
homepage/src/routes/hikes/[slug]/+page.svelte
T
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

636 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import HikeMap from '$lib/components/hikes/HikeMap.svelte';
import HikePhotoStrip from '$lib/components/hikes/HikePhotoStrip.svelte';
import ElevationProfile from '$lib/components/hikes/ElevationProfile.svelte';
import Seo from '$lib/components/Seo.svelte';
import { setHikeContext } from '$lib/components/hikes/hikeContext.svelte';
import { listScrollAnchors } from '$lib/components/hikes/scrollAnchors';
import { setHover, clearHover } from '$lib/components/hikes/hoverStore.svelte';
import { focused, setFocused } from '$lib/components/hikes/focusedImageStore.svelte';
import Route from '@lucide/svelte/icons/route';
import Clock from '@lucide/svelte/icons/clock';
import TrendingUp from '@lucide/svelte/icons/trending-up';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import ArrowUpToLine from '@lucide/svelte/icons/arrow-up-to-line';
import ArrowDownToLine from '@lucide/svelte/icons/arrow-down-to-line';
import CalendarRange from '@lucide/svelte/icons/calendar-range';
import Download from '@lucide/svelte/icons/download';
import { buildGpx, type GpxWritePoint } from '$lib/gpx';
import type { HikeTrackPoint } from '$types/hikes';
import type { PageProps } from './$types';
const { data }: PageProps = $props();
const { hike } = data;
const MdxComponent = $derived(data.MdxComponent as unknown as typeof import('svelte').SvelteComponent);
const showPrivate = $derived(!!data.session?.user);
let track = $state<HikeTrackPoint[] | null>(null);
let trackError = $state<string | null>(null);
$effect(() => {
let aborted = false;
fetch(hike.trackUrl)
.then((r) => {
if (!r.ok) throw new Error(`Track fetch failed: ${r.status}`);
return r.json() as Promise<HikeTrackPoint[]>;
})
.then((data) => {
if (!aborted) track = data;
})
.catch((err: Error) => {
if (!aborted) trackError = err.message;
});
return () => {
aborted = true;
};
});
const durationLabel = $derived(
hike.durationMin !== null && hike.durationMin > 0
? `${Math.floor(hike.durationMin / 60)}h ${hike.durationMin % 60}m`
: '—'
);
// Map SAC tier to the painted-rectangle trail-marker colour scheme used
// in Switzerland: T1 = yellow Wanderweg, T2/T3 = white-red-white
// Bergwanderweg, T4T6 = white-blue-white Alpinwanderweg.
const sacBand = $derived.by<'yellow' | 'red' | 'blue'>(() => {
if (hike.difficulty === 'T1') return 'yellow';
if (hike.difficulty === 'T2' || hike.difficulty === 'T3') return 'red';
return 'blue';
});
const MONTHS_DE_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
const seasonLabel = $derived.by(() => {
const a = hike.seasonStart;
const b = hike.seasonEnd;
if (a == null || b == null) return null;
if (a < 1 || a > 12 || b < 1 || b > 12) return null;
return `${MONTHS_DE_SHORT[a - 1]}${MONTHS_DE_SHORT[b - 1]}`;
});
// Filter visibility once at the page level so the map and the photo strip
// operate on the same index space — focused indexes are positions in this
// shared array.
const visibleImagePoints = $derived(
showPrivate
? hike.imagePoints
: hike.imagePoints.filter((ip) => ip.visibility !== 'private')
);
// Expose both the full chronological list and the visibility-filtered
// list to `<HikeImage>` instances embedded in the MDX body. The track
// is exposed too so each HikeImage can resolve its timestamp to a
// track index for the scroll-progress pin.
setHikeContext(() => ({
images: hike.imagePoints,
visibleImages: visibleImagePoints,
track
}));
// Continuous trail-position tracking. As the reader scrolls through the
// content column, we sample every registered `<HikeImage>` anchor's
// viewport position and linearly interpolate between adjacent images'
// track indices using the viewport's vertical midpoint as the cursor.
// The result is pushed into the hover store, so the sticky map's pin
// glides along the trail just like it does for chart hovers.
$effect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(min-width: 1024px)');
if (!mq.matches) return;
let frame: number | null = null;
let lastHoverIdx = -1;
let lastFocusIdx: number | null = null;
function sample(): void {
frame = null;
const anchors = listScrollAnchors();
if (anchors.length === 0) return;
// Sort by current viewport-top — that's the natural reading order
// even if a couple of images were rendered out of chronological
// sequence in the prose.
const sorted = anchors
.map((a) => ({
top: a.element.getBoundingClientRect().top,
trackIdx: a.trackIdx,
visibleIdx: a.visibleIdx
}))
.sort((a, b) => a.top - b.top);
const anchorY = window.innerHeight / 2;
let trackIdx: number;
let visibleIdx: number;
if (anchorY <= sorted[0].top) {
trackIdx = sorted[0].trackIdx;
visibleIdx = sorted[0].visibleIdx;
} else if (anchorY >= sorted[sorted.length - 1].top) {
const last = sorted[sorted.length - 1];
trackIdx = last.trackIdx;
visibleIdx = last.visibleIdx;
} else {
// Find the bracketing pair and interpolate.
let lo = sorted[0];
let hi = sorted[sorted.length - 1];
for (let i = 0; i < sorted.length - 1; i++) {
if (anchorY >= sorted[i].top && anchorY < sorted[i + 1].top) {
lo = sorted[i];
hi = sorted[i + 1];
break;
}
}
const span = hi.top - lo.top || 1;
const frac = (anchorY - lo.top) / span;
trackIdx = Math.round(lo.trackIdx + frac * (hi.trackIdx - lo.trackIdx));
// "Nearest" image — whichever bracket endpoint we're closer to.
visibleIdx = frac < 0.5 ? lo.visibleIdx : hi.visibleIdx;
}
if (trackIdx !== lastHoverIdx) {
lastHoverIdx = trackIdx;
setHover(trackIdx, 'scroll');
}
if (visibleIdx !== lastFocusIdx && focused.source !== 'strip' && focused.source !== 'map') {
lastFocusIdx = visibleIdx;
setFocused(visibleIdx, 'inline');
}
}
function onScroll(): void {
if (frame !== null) return;
frame = requestAnimationFrame(sample);
}
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
// One initial sample so the pin sits at the right place on page load.
onScroll();
return () => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
if (frame !== null) cancelAnimationFrame(frame);
// Clear the scroll-driven hover so the pin disappears if the user
// navigates away from the page.
clearHover();
};
});
// Client-side GPX export of just the track (no image waypoints). Built
// from the already-loaded JSON track so we don't hit the network again.
function downloadGpx(): void {
if (!track || track.length === 0) return;
const points: GpxWritePoint[] = track.map(([lng, lat, ele, t]) => ({
lat,
lng,
altitude: typeof ele === 'number' ? ele : undefined,
timestamp: typeof t === 'number' ? t : null
}));
const gpx = buildGpx({ name: hike.title, trackPoints: points });
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${hike.slug}.gpx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
<Seo
title={`${hike.title} · Wanderungen`}
description={hike.summary}
ogType="article"
ogImage={hike.cover.src || undefined}
ogImageAlt={hike.cover.alt || undefined}
lang="de"
/>
<svelte:head>
<link
rel="preload"
as="fetch"
href={hike.trackUrl}
type="application/json"
crossorigin="anonymous"
/>
<link rel="preconnect" href="https://wmts.geo.admin.ch" crossorigin="anonymous" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</svelte:head>
<article class="hike-detail">
<!-- The map IS the hero: the trail is the most informative thing about a
hike, so we lead with it. Title overlays at the bottom-left. A second
HikeMap further down sticks in the scroll-area; both share state via
the focusedImageStore so they animate together. -->
<section class="hero-map" style="view-transition-name: hike-{hike.slug}">
{#if track && track.length > 0}
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate />
{:else if trackError}
<div class="map-fallback">Track konnte nicht geladen werden: {trackError}</div>
{:else}
<div class="map-fallback">Track wird geladen…</div>
{/if}
<div class="hero-title">
<h1>{hike.title}</h1>
{#if hike.region}
<p class="region">
{hike.region}{hike.canton && hike.canton !== hike.region ? `, ${hike.canton}` : ''}
</p>
{/if}
</div>
</section>
<section class="metrics" aria-label="Tourendaten">
{#if hike.icon}
<img class="route-icon" src={hike.icon} alt="" aria-hidden="true" />
{/if}
<div class="metric">
<Route size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{hike.distanceKm.toFixed(1)}<span class="value-unit">km</span></span>
<span class="unit">Distanz</span>
</div>
<div class="metric">
<Clock size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{durationLabel}</span>
<span class="unit">Dauer</span>
</div>
<div class="metric">
<TrendingUp size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{hike.elevationGainM}<span class="value-unit">m</span></span>
<span class="unit">Aufstieg</span>
</div>
<div class="metric">
<TrendingDown size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{hike.elevationLossM}<span class="value-unit">m</span></span>
<span class="unit">Abstieg</span>
</div>
{#if hike.elevationMaxM !== null}
<div class="metric">
<ArrowUpToLine size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{hike.elevationMaxM}<span class="value-unit">m</span></span>
<span class="unit">höchster</span>
</div>
{/if}
{#if hike.elevationMinM !== null}
<div class="metric">
<ArrowDownToLine size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{hike.elevationMinM}<span class="value-unit">m</span></span>
<span class="unit">tiefster</span>
</div>
{/if}
<div class="metric">
<span class="sac-marker sac-marker-{sacBand}" aria-hidden="true"></span>
<span class="value">{hike.difficulty}</span>
<span class="unit">SAC</span>
</div>
{#if seasonLabel}
<div class="metric">
<CalendarRange size={20} strokeWidth={1.75} aria-hidden="true" />
<span class="value">{seasonLabel}</span>
<span class="unit">Saison</span>
</div>
{/if}
</section>
{#if track && track.length > 0}
<section class="elev-area">
<ElevationProfile {track} />
</section>
{/if}
<div class="actions">
<button
type="button"
class="download-btn"
onclick={downloadGpx}
disabled={!track || track.length === 0}
title="GPX-Datei mit nur dem Track (ohne Bilder) herunterladen"
>
<Download size={16} strokeWidth={2} aria-hidden="true" />
GPX herunterladen
</button>
</div>
{#if track && track.length > 0 && visibleImagePoints.length > 0}
<section class="strip-area">
<HikePhotoStrip images={visibleImagePoints} {track} />
</section>
{/if}
<section class="scroll-area">
<aside class="trail-col">
{#if track && track.length > 0}
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate />
<ElevationProfile {track} />
{/if}
</aside>
<section class="content-col">
<MdxComponent />
</section>
</section>
</article>
<style>
.hike-detail {
max-width: 1400px;
margin-inline: auto;
padding: 0 0 4rem;
}
/* Hero map is full-bleed: breaks out of the centered `.hike-detail`
* container to span the entire viewport width and extends *under* the
* sticky nav (which is glass-blurred and sits above with z-index). The
* `calc(50% - 50vw)` trick stretches a child of a centered parent
* edge-to-edge; the negative top margin pulls the map back up over
* the gap that the nav's height + top-margin would otherwise leave. */
.hero-map {
position: relative;
width: 100vw;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
margin-top: calc(-1 * (3rem + max(12px, env(safe-area-inset-top, 0px) + 4px)));
margin-bottom: 0;
overflow: hidden;
}
.hero-map :global(.map) {
height: clamp(360px, 60vh, 640px);
border-radius: 0;
box-shadow: none;
}
/* Push Leaflet's top-left controls (zoom +/-) below the sticky nav so
* they aren't covered on narrow viewports where the nav spans the
* full width. The bottom-right controls (layer toggle, photo toggle,
* GPS) sit clear of the nav already. */
.hero-map :global(.leaflet-top) {
top: calc(3rem + max(12px, env(safe-area-inset-top, 0px) + 4px) + 0.5rem);
}
.hero-title {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 1.5rem 1.5rem 1.25rem;
background: linear-gradient(to top, rgb(0 0 0 / 0.6), transparent);
color: white;
pointer-events: none;
z-index: 400;
}
.hero-title h1 {
margin: 0;
font-size: clamp(1.5rem, 4vw, 2.2rem);
text-shadow: 0 2px 8px rgb(0 0 0 / 0.45);
}
.region {
margin: 0.2rem 0 0;
opacity: 0.9;
text-shadow: 0 1px 4px rgb(0 0 0 / 0.45);
}
.metrics {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem 2.25rem;
padding: 1.5rem 1rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.route-icon {
width: 96px;
height: 96px;
object-fit: contain;
flex-shrink: 0;
}
.metric {
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto;
column-gap: 0.55rem;
row-gap: 0.05rem;
align-items: center;
}
.metric :global(svg) {
grid-row: 1 / span 2;
color: var(--color-primary);
}
.metrics .value {
font-size: 1.35rem;
line-height: 1.1;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.value-unit {
font-size: 0.7em;
font-weight: 500;
color: var(--color-text-secondary);
margin-left: 0.15em;
}
.metrics .unit {
font-size: 0.78rem;
color: var(--color-text-tertiary);
letter-spacing: 0.02em;
}
/* SAC trail-marker pictograms in landscape orientation.
* T1: yellow Wegweiser-style sign with a right-pointing arrow tip.
* T2/T3: white-red-white painted Bergwanderweg marker.
* T4T6: white-blue-white painted Alpinwanderweg marker. */
.sac-marker {
grid-row: 1 / span 2;
width: 44px;
height: 20px;
}
.sac-marker-yellow {
width: 32px;
background: #f5a623;
/* Pentagon → flat left, arrow point on the right, like a Swiss
* hiking-trail Wegweiser. Clip-path overrides any border so the
* outline is supplied by filter: drop-shadow instead. */
clip-path: polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%);
filter: drop-shadow(0 0 0.5px rgb(0 0 0 / 0.45));
}
.sac-marker-red {
border-radius: 2px;
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.18);
background: linear-gradient(
to bottom,
#fff 0 25%,
#dc1d2a 25% 75%,
#fff 75% 100%
);
}
.sac-marker-blue {
border-radius: 2px;
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.18);
background: linear-gradient(
to bottom,
#fff 0 25%,
#2965c8 25% 75%,
#fff 75% 100%
);
}
.actions {
display: flex;
justify-content: center;
padding: 0 1rem 1rem;
}
.download-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.5rem 1.1rem;
font: inherit;
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text-primary);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition:
color var(--transition-fast),
border-color var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.download-btn:hover:not(:disabled) {
color: var(--color-primary);
border-color: var(--color-primary);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.download-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.download-btn :global(svg) {
color: var(--color-primary);
}
.elev-area {
padding: 0 1rem;
margin-top: 0.25rem;
}
.strip-area {
padding-inline: 1rem;
margin-top: 0.5rem;
}
.scroll-area {
padding-inline: 1rem;
margin-top: 1.5rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
/* Mobile: the hero map at the top is the only map; the secondary sticky
* map (and the elevation profile that lived next to it) are redundant
* since there's no scrollytelling without the two-column layout. */
.trail-col {
display: none;
}
.trail-col,
.content-col {
min-width: 0;
}
.content-col {
font-size: 1rem;
line-height: 1.65;
}
.content-col :global(p) {
margin: 0 0 1.2rem;
}
.content-col :global(h2) {
margin: 2rem 0 0.75rem;
font-size: 1.5rem;
color: var(--color-text-primary);
}
.content-col :global(blockquote) {
margin: 1.5rem 0;
padding: 0.6rem 1rem;
border-left: 3px solid var(--color-primary);
background: var(--color-surface);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
font-style: italic;
}
/* Desktop scrollytelling: a sticky trail column on the left holding a
* smaller copy of the map + elevation profile, with prose + inline
* images flowing on the right. Below 1024 px the columns stack and the
* trail loses its stickiness.
*
* For `position: sticky` to actually engage, the grid item's own height
* must be smaller than the row's resolved height — `align-self: start`
* stops the grid from stretching the cell to the row's full height
* (which would otherwise leave no scroll room for the sticky to move
* against). The trail-col contains only the secondary map + elevation
* here (the strip lives above, the photos inline), so it stays short. */
@media (min-width: 1024px) {
.scroll-area {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 2.5rem;
align-items: start;
}
.trail-col {
display: block;
position: sticky;
/* The global nav is itself sticky (3 rem tall, ~12 px top offset),
* so anchor the map below it with a small breathing gap. */
top: calc(3rem + max(12px, env(safe-area-inset-top, 0px) + 4px) + 0.75rem);
align-self: start;
}
.trail-col :global(.map) {
height: 400px;
border-radius: var(--radius-card);
}
.trail-col :global(.elevation) {
height: 180px;
}
}
.map-fallback {
padding: 4rem 1rem;
text-align: center;
color: var(--color-text-tertiary);
background: var(--color-surface);
border-radius: var(--radius-card);
}
</style>