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
@@ -0,0 +1,513 @@
<script lang="ts">
import type { Attachment } from 'svelte/attachments';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { HikeManifestEntry, Difficulty } 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[];
}
const { hikes }: Props = $props();
// Per-tier polyline colour, matching the painted-marker scheme on the
// SAC badges. Canvas-rendered polylines can't resolve CSS variables,
// so the values are hard-coded — keep in sync with HikeCard.svelte.
const SAC_COLOR: Record<Difficulty, string> = {
T1: '#f5a623',
T2: '#dc1d2a',
T3: '#dc1d2a',
T4: '#2965c8',
T5: '#2965c8',
T6: '#2965c8'
};
const SWISSTOPO_FARBE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg';
const SWISSTOPO_IMAGE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg';
const SWISSTOPO_DUFOUR = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hiks-dufour/default/current/3857/{z}/{x}/{y}.png';
const SWISSTOPO_ATTRIBUTION =
'&copy; <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a>';
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = {
schematic: { label: 'Karte', icon: Map, maxZoom: 19 },
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;
const map = L.map(node, {
attributionControl: true,
zoomControl: true,
preferCanvas: true
}).setView([46.8, 8.3], 8);
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
schematic: L.tileLayer(SWISSTOPO_FARBE, {
maxZoom: LAYER_DEFS.schematic.maxZoom,
minZoom: 7,
attribution: SWISSTOPO_ATTRIBUTION,
updateWhenZooming: false
}),
aerial: L.tileLayer(SWISSTOPO_IMAGE, {
maxZoom: LAYER_DEFS.aerial.maxZoom,
minZoom: 7,
attribution: SWISSTOPO_ATTRIBUTION,
updateWhenZooming: false
}),
dufour: L.tileLayer(SWISSTOPO_DUFOUR, {
maxZoom: LAYER_DEFS.dufour.maxZoom,
minZoom: 7,
attribution: SWISSTOPO_ATTRIBUTION,
updateWhenZooming: false
})
};
tileLayers.schematic.addTo(map);
let currentBase: BaseLayer = 'schematic';
// One polyline per hike, sourced from the manifest's already-
// simplified previewPolyline (≤30 points each).
const layer = L.layerGroup().addTo(map);
const bounds = 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 = SAC_COLOR[hike.difficulty] ?? '#5e81ac';
const poly = L.polyline(latLngs, {
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) {
bounds.extend([lat, lng]);
}
}
let initialBounds: ReturnType<typeof L.latLngBounds> | null = null;
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [32, 32], maxZoom: 13 });
initialBounds = bounds;
recenterMap = () => {
if (!initialBounds) return;
map.flyToBounds(initialBounds, {
padding: [32, 32],
maxZoom: 13,
duration: 0.6,
easeLinearity: 0.25
});
};
}
// 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(() => {
$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://wmts.geo.admin.ch" 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>