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:
@@ -0,0 +1,220 @@
|
||||
<script lang="ts">
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import HikeCard from '$lib/components/hikes/HikeCard.svelte';
|
||||
import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.svelte';
|
||||
import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte';
|
||||
import Seo from '$lib/components/Seo.svelte';
|
||||
import type { Difficulty } from '$types/hikes';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
const { data }: PageProps = $props();
|
||||
|
||||
// Filter ceilings start wide-open so the initial render (SSR + first
|
||||
// hydration pass) shows every hike. `$effect` below clamps them down
|
||||
// to the actual data maxes once `data.hikes` is fully populated —
|
||||
// reading `data.hikes` synchronously at script-init turned out to be
|
||||
// fragile during dev hydration (it sporadically returned a one-hike
|
||||
// subset, which then locked the filter to that one hike until the
|
||||
// next navigation cycle).
|
||||
const filter = $state<HikesFilter>({
|
||||
maxDistanceKm: Number.POSITIVE_INFINITY,
|
||||
maxDurationMin: Number.POSITIVE_INFINITY,
|
||||
maxGainM: Number.POSITIVE_INFINITY,
|
||||
maxLossM: Number.POSITIVE_INFINITY,
|
||||
difficulties: new SvelteSet<Difficulty>(),
|
||||
regions: new SvelteSet<string>()
|
||||
});
|
||||
|
||||
// One-shot per mount: set the slider ceilings to the actual data maxes.
|
||||
// Runs once after `data.hikes` is non-empty; the inner reads of every
|
||||
// `distanceKm`/`durationMin`/etc. fall under the same effect so a
|
||||
// subsequent data-only update would also refresh the defaults — but for
|
||||
// this prerendered, static-data page that's effectively a no-op.
|
||||
let filterDefaultsApplied = false;
|
||||
$effect(() => {
|
||||
if (filterDefaultsApplied) return;
|
||||
if (data.hikes.length === 0) return;
|
||||
filter.maxDistanceKm = Math.max(1, ...data.hikes.map((h) => Math.ceil(h.distanceKm)));
|
||||
filter.maxDurationMin = Math.max(60, ...data.hikes.map((h) => h.durationMin ?? 0));
|
||||
filter.maxGainM = Math.max(100, ...data.hikes.map((h) => h.elevationGainM));
|
||||
filter.maxLossM = Math.max(100, ...data.hikes.map((h) => h.elevationLossM));
|
||||
filterDefaultsApplied = true;
|
||||
});
|
||||
|
||||
const visible = $derived.by(() => {
|
||||
const out = [];
|
||||
for (const h of data.hikes) {
|
||||
if (h.distanceKm > filter.maxDistanceKm) continue;
|
||||
if ((h.durationMin ?? 0) > filter.maxDurationMin) continue;
|
||||
if (h.elevationGainM > filter.maxGainM) continue;
|
||||
if (h.elevationLossM > filter.maxLossM) continue;
|
||||
if (filter.difficulties.size > 0 && !filter.difficulties.has(h.difficulty)) continue;
|
||||
if (filter.regions.size > 0 && (!h.region || !filter.regions.has(h.region))) continue;
|
||||
out.push(h);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Lightweight totals strip over the currently-filtered subset — gives
|
||||
// the user a sense of what they're looking at without having to scan
|
||||
// every card.
|
||||
const totals = $derived.by(() => {
|
||||
let km = 0;
|
||||
let gain = 0;
|
||||
for (const h of visible) {
|
||||
km += h.distanceKm;
|
||||
gain += h.elevationGainM;
|
||||
}
|
||||
return {
|
||||
km: Math.round(km),
|
||||
gain: Math.round(gain)
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<Seo
|
||||
title="Wanderungen"
|
||||
description="Wanderberichte mit interaktiver Karte, Höhenprofil und GPX-Track."
|
||||
lang="de"
|
||||
/>
|
||||
|
||||
<section class="hikes-page">
|
||||
<section class="hero-map" aria-label="Übersicht">
|
||||
<HikesOverviewMap hikes={visible} />
|
||||
</section>
|
||||
|
||||
<div class="below-hero">
|
||||
<header class="page-header">
|
||||
<p class="subtitle">
|
||||
<strong>{visible.length}</strong> von {data.hikes.length} Touren
|
||||
</p>
|
||||
{#if visible.length > 0}
|
||||
<dl class="totals" aria-label="Gesamtsumme der gefilterten Touren">
|
||||
<div><dt>Distanz</dt><dd>{totals.km} km</dd></div>
|
||||
<div><dt>Aufstieg</dt><dd>{totals.gain.toLocaleString('de-CH')} m</dd></div>
|
||||
</dl>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<HikesFilterBar hikes={data.hikes} {filter} />
|
||||
|
||||
{#if visible.length === 0}
|
||||
<p class="empty">Keine Wanderung entspricht den aktuellen Filtern.</p>
|
||||
{:else}
|
||||
<ul class="grid">
|
||||
{#each visible as hike (hike.slug)}
|
||||
<li>
|
||||
<HikeCard {hike} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hikes-page {
|
||||
max-width: 1200px;
|
||||
margin-inline: auto;
|
||||
padding: 0 0 3rem;
|
||||
}
|
||||
|
||||
/* Full-bleed hero, matching the detail-page hero: edge-to-edge via
|
||||
* `calc(50% - 50vw)` and pulled up under the glass-blurred sticky nav
|
||||
* with a negative top margin equal to the nav's height.
|
||||
* `isolation: isolate` creates a stacking context so Leaflet's
|
||||
* z-index:200+ panes can't escape this section and render over the
|
||||
* sticky nav (which sits at z-index 100). The detail-page hero gets
|
||||
* this same effect for free because it sets `view-transition-name`. */
|
||||
.hero-map {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Push Leaflet's top-left controls below the sticky nav. */
|
||||
.hero-map :global(.leaflet-top) {
|
||||
top: calc(3rem + max(12px, env(safe-area-inset-top, 0px) + 4px) + 0.5rem);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.subtitle strong {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.totals {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.totals div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.05rem;
|
||||
}
|
||||
|
||||
.totals dt {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.totals dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1.5rem 0 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user