Files
homepage/src/routes/hikes/+page.svelte
T
Alexander 8a67f5fba8 feat(hikes): medium hero variant + Switzerland-framed overview, drop static→live wobble
Three related improvements to the pre-rendered hero map system:

* New medium viewport variant (561–899 CSS px) for the per-hike detail
  hero and the /hikes overview. Tablet/split-pane viewports were
  getting the wide pose (chosen for ~1920 CSS px), which landed too
  zoomed in. Each variant is rendered at a pose matching its
  container, so the static→Leaflet handover aligns at every band.
  Manifest fields are optional — pages fall back to the wide variant
  on tablets until build-hikes regenerates the images.

* Overview frames on Switzerland (fixed center [46.82, 8.23]) with
  explicit per-variant zooms (wide=8, medium=8, narrow=7) rather than
  auto-fitting the union of hike bboxes. The previous behavior zoomed
  in on whichever corner the catalogue clustered in; this reads as
  "hikes across CH". Bumps OVERVIEW_RENDER_VERSION so cached overview
  images get invalidated on the next build.

* Removed the post-tile-load flyToBounds in both HikeMap.svelte and
  HikesOverviewMap.svelte. The map already opens at the static pose
  via setView; the second auto-fit was adding a visible wobble on
  routes whose bbox sits at an integer-zoom boundary (e.g. the
  Einsiedeln–Unteriberg detail), where the build-time fit and
  Leaflet's runtime fit disagree by one zoom step at the user's
  actual container size.
2026-05-26 11:51:48 +02:00

440 lines
15 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 { SvelteSet } from 'svelte/reactivity';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
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 { HIKES_OVERVIEW } from '$lib/data/hikes.generated';
import { hikeFilterBounds } from '$lib/hikes/filterBounds';
import { resolveHikeArea } from '$lib/hikes/hikeArea';
import type { Difficulty } from '$types/hikes';
// True when the current month falls inside the hike's recommended season
// window. Windows can wrap the new year (start > end, e.g. 113 for winter);
// a missing/invalid window counts as year-round (always in season).
function isInSeason(start: number | null | undefined, end: number | null | undefined, month: number): boolean {
if (start == null || end == null) return true;
if (start < 1 || start > 12 || end < 1 || end > 12) return true;
return start <= end ? month >= start && month <= end : month >= start || month <= end;
}
import type { PageProps } from './$types';
const { data }: PageProps = $props();
// Fades the SSR-rendered static overview hero out once Leaflet's first
// schematic-tile batch has loaded. Same handover pattern as the detail
// page's hero map.
let heroMapReady = $state(false);
// Three-band viewport switch — drives which pre-rendered pose
// (`HIKES_OVERVIEW.zoom/center` vs. `.zoomMedium/.centerMedium` vs.
// `.zoomNarrow/.centerNarrow`) we hand to Leaflet's first `setView` so
// it lands aligned with whichever static `<img>` the CSS is showing.
// Starts `false`/`false` to match SSR (which has no window); the
// $effect snaps to the real value on mount and keeps both flags in
// sync across rotate/resize. `narrow` wins over `medium` when both
// would match (≤560 is also <900).
let narrowViewport = $state(false);
let mediumViewport = $state(false);
$effect(() => {
if (typeof window === 'undefined') return;
const mqNarrow = window.matchMedia('(max-width: 560px)');
const mqMedium = window.matchMedia('(min-width: 561px) and (max-width: 899px)');
narrowViewport = mqNarrow.matches;
mediumViewport = mqMedium.matches;
const onNarrow = (e: MediaQueryListEvent) => { narrowViewport = e.matches; };
const onMedium = (e: MediaQueryListEvent) => { mediumViewport = e.matches; };
mqNarrow.addEventListener('change', onNarrow);
mqMedium.addEventListener('change', onMedium);
return () => {
mqNarrow.removeEventListener('change', onNarrow);
mqMedium.removeEventListener('change', onMedium);
};
});
const overviewPose = $derived.by(() => {
if (!HIKES_OVERVIEW) return null;
if (narrowViewport && HIKES_OVERVIEW.urlNarrow && HIKES_OVERVIEW.centerNarrow && typeof HIKES_OVERVIEW.zoomNarrow === 'number') {
return { center: HIKES_OVERVIEW.centerNarrow, zoom: HIKES_OVERVIEW.zoomNarrow };
}
if (mediumViewport && HIKES_OVERVIEW.urlMedium && HIKES_OVERVIEW.centerMedium && typeof HIKES_OVERVIEW.zoomMedium === 'number') {
return { center: HIKES_OVERVIEW.centerMedium, zoom: HIKES_OVERVIEW.zoomMedium };
}
return { center: HIKES_OVERVIEW.center, zoom: HIKES_OVERVIEW.zoom };
});
// 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>({
minDistanceKm: Number.NEGATIVE_INFINITY,
maxDistanceKm: Number.POSITIVE_INFINITY,
minDurationMin: Number.NEGATIVE_INFINITY,
maxDurationMin: Number.POSITIVE_INFINITY,
minGainM: Number.NEGATIVE_INFINITY,
maxGainM: Number.POSITIVE_INFINITY,
minLossM: Number.NEGATIVE_INFINITY,
maxLossM: Number.POSITIVE_INFINITY,
difficulties: new SvelteSet<Difficulty>(),
regions: new SvelteSet<string>(),
areas: new SvelteSet<string>(),
tags: new SvelteSet<string>(),
inSeasonOnly: false
});
// Tag deep-link: arrival from a detail-page tag chip (`/hikes?tag=winter`)
// or any saved URL with `?tag=...` pre-selects those tags. Runs once on
// mount; thereafter the URL writer below is the source of truth.
let initialTagsApplied = false;
$effect(() => {
if (initialTagsApplied) return;
if (typeof window === 'undefined') return;
const params = page.url.searchParams.getAll('tag');
for (const t of params) if (t) filter.tags.add(t);
initialTagsApplied = true;
});
// Tag URL sync: every toggle in the filter bar reflects into the URL
// so the page is shareable / back-button-restorable. `replaceState`
// rather than `goto` keeps history clean — toggling four tags would
// otherwise leave four back-button stops.
$effect(() => {
if (typeof window === 'undefined' || !initialTagsApplied) return;
const url = new URL(window.location.href);
const wanted = [...filter.tags].sort();
const current = url.searchParams.getAll('tag').slice().sort();
// Skip the no-op rewrite path — `replaceState` would still touch
// history's state object and trigger downstream `page.url` effects
// for no UX benefit.
if (
wanted.length === current.length &&
wanted.every((t, i) => t === current[i])
) return;
url.searchParams.delete('tag');
for (const t of wanted) url.searchParams.append('tag', t);
replaceState(url, page.state);
});
// 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;
const b = hikeFilterBounds(data.hikes);
filter.minDistanceKm = b.distance.min;
filter.maxDistanceKm = b.distance.max;
filter.minDurationMin = b.duration.min;
filter.maxDurationMin = b.duration.max;
filter.minGainM = b.gain.min;
filter.maxGainM = b.gain.max;
filter.minLossM = b.loss.min;
filter.maxLossM = b.loss.max;
filterDefaultsApplied = true;
});
const visible = $derived.by(() => {
const out = [];
const currentMonth = new Date().getMonth() + 1;
for (const h of data.hikes) {
if (h.distanceKm < filter.minDistanceKm || h.distanceKm > filter.maxDistanceKm) continue;
const dur = h.durationMin ?? 0;
if (dur < filter.minDurationMin || dur > filter.maxDurationMin) continue;
if (h.elevationGainM < filter.minGainM || h.elevationGainM > filter.maxGainM) continue;
if (h.elevationLossM < filter.minLossM || 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;
if (filter.areas.size > 0) {
const area = resolveHikeArea(h.canton, h.country);
if (!area || !filter.areas.has(area.value)) continue;
}
if (filter.inSeasonOnly && !isInSeason(h.seasonStart, h.seasonEnd, currentMonth)) continue;
// Multi-tag = OR (a hike matching ANY selected tag is shown). AND
// would shrink the listing to ~zero quickly given how few tags
// most hikes have; OR matches how detail-page chips feel like
// "show me more like this".
if (filter.tags.size > 0) {
let any = false;
for (const t of h.tags) {
if (filter.tags.has(t)) { any = true; break; }
}
if (!any) 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">
{#if HIKES_OVERVIEW}
<!-- Build-time static composite of Swisstopo tiles + every
visible hike's preview polyline, coloured by SAC tier.
Displayed at native pixel size (`object-fit: none`) so it
overlays Leaflet's live tiles exactly. The image fades out
once Leaflet's first tile batch loads. Three width variants
ship — wide (≥900 CSS px), medium (561899), narrow (≤560).
CSS chooses which one shows based on a media query so
hydration doesn't need to wait. -->
<img
class="hero-static hero-static-wide"
class:faded={heroMapReady}
src={HIKES_OVERVIEW.url}
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
/>
{#if HIKES_OVERVIEW.urlMedium}
<img
class="hero-static hero-static-medium"
class:faded={heroMapReady}
src={HIKES_OVERVIEW.urlMedium}
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
/>
{/if}
{#if HIKES_OVERVIEW.urlNarrow}
<img
class="hero-static hero-static-narrow"
class:faded={heroMapReady}
src={HIKES_OVERVIEW.urlNarrow}
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
/>
{/if}
{/if}
<HikesOverviewMap
hikes={visible}
initialCenter={overviewPose?.center}
initialZoom={overviewPose?.zoom}
onReady={() => (heroMapReady = true)}
/>
</section>
<div class="below-hero">
<!-- Wrapped in a named view-transition box so the filter bar can fly
up alongside the cards when arriving at /hikes from outside the
hikes group. Same `view-transition-class: hike-fly-in` as each
HikeCard so one CSS rule animates both. -->
<div class="filter-vt-box">
<HikesFilterBar
hikes={data.hikes}
{filter}
resultCount={visible.length}
totalCount={data.hikes.length}
totalKm={totals.km}
totalGain={totals.gain}
/>
</div>
{#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}
<!-- Tiny swisstopo credit. The map's own attribution control is hidden
for a cleaner frame, but their tile licence still requires the
credit to appear somewhere on the page. -->
<footer class="map-credit">
Kartendaten &copy;
<a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener noreferrer">swisstopo</a>
·
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a>,
<a href="https://opentopomap.org/" target="_blank" rel="noopener noreferrer">OpenTopoMap</a>
·
<a href="https://www.esri.com/" target="_blank" rel="noopener noreferrer">Esri</a>
</footer>
</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;
/* Reserve the eventual map height up-front so the static image and
* Leaflet's tile pane sit on a stable surface (no scroll-shift when
* either mounts). Same clamp as `:global(.overview-map)` inside
* the HikesOverviewMap component. */
min-height: clamp(320px, 50vh, 520px);
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;
/* Transparent so the page background shows through any tile gap
* during the static→live cross-fade rather than Leaflet's grey
* default. */
background: transparent;
}
/* Pre-rendered overview hero. Native pixel size + centred so it matches
* Leaflet's tile rendering 1:1; `cover` would scale and break alignment
* during the cross-fade. Wider viewports just reveal more of the
* 3840×2400 canvas; the union bbox (where the trails live) is always
* pixel-aligned with the live map. */
.hero-static {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: none;
object-position: center;
z-index: 1;
opacity: 1;
transition: opacity 450ms ease;
pointer-events: none;
}
.hero-static.faded {
opacity: 0;
}
/* Three-band viewport swap (wide ≥900, medium 561899, narrow ≤560).
* Each variant is rendered at a fit matching its band so Leaflet picks
* the same zoom on first paint — without this the desktop hero would
* land too zoomed-in on tablets/phones (its pose was chosen for
* ~1920 CSS px), and the narrow hero would land too zoomed-out on
* tablets (chosen for ~400 CSS px). */
.hero-static-medium,
.hero-static-narrow { display: none; }
@media (max-width: 899px) {
.hero-static-wide { display: none; }
.hero-static-medium { display: block; }
}
@media (max-width: 560px) {
.hero-static-medium { display: none; }
.hero-static-narrow { display: block; }
}
/* Live overview map sits above the static; transparent so the static
* shows through until Leaflet's tile pane paints over it. */
.hero-map :global(.overview-map) {
position: relative;
z-index: 2;
background: transparent;
}
/* 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);
}
/* Breathing room between the full-bleed hero map and the filter bar. */
.below-hero {
margin-top: 2rem;
}
.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;
}
/* Tiny, muted map-licence credit at the very bottom of the listing. */
.map-credit {
margin-top: 2.5rem;
padding: 0 1rem;
text-align: center;
font-size: 0.72rem;
color: var(--color-text-tertiary);
}
.map-credit a {
color: inherit;
text-decoration: underline;
text-decoration-color: color-mix(in oklab, currentColor 35%, transparent);
text-underline-offset: 0.18em;
transition: color var(--transition-fast);
}
.map-credit a:hover {
color: var(--color-primary);
}
@media (max-width: 560px) {
.grid {
grid-template-columns: 1fr;
gap: 1rem;
}
}
/* Wrapper sets the view-transition name/class on the filter bar so the
* same .hike-fly-in rules in app.css that animate the cards also
* animate this bar (fly-in on /hikes enter, fly-out on /hikes exit). */
.filter-vt-box {
view-transition-name: hikes-filter-bar;
view-transition-class: hike-fly-in;
}
</style>