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).
This commit is contained in:
2026-05-18 21:13:00 +02:00
parent 928774084f
commit f3d16d5187
52 changed files with 8817 additions and 103 deletions
+635
View File
@@ -0,0 +1,635 @@
<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>