Files
homepage/src/routes/hikes/+page.svelte
T
Alexander 896e42f5d9 feat(hikes): redesign /hikes filter as a quiet command bar
Replace the flat always-open filter slab with a collapsed command bar:
a result/totals summary, removable active-filter chips, and a Filter
toggle (with active-count badge). The control panel expands in-flow
below the bar (slide), pushing the card grid down instead of overlaying
it, with breathing room added between the hero map and the bar.

- Range filters are now dual-thumb sliders (lower + upper bound) for
  distance, duration, ascent and descent, via a new RangeSlider
  component (pointer + keyboard, crossover hand-off). Shared bounds math
  lives in filterBounds.ts so slider extents and the page's default
  filter state can't drift.
- Difficulty selection renders the actual SAC trail-sign markers
  (T1 yellow Wegweiser, T2/T3 white-red-white, T4-T6 white-blue-white),
  matching the hike cards; selected signs light up in full colour while
  unselected ones are dimmed.
- Tag selection uses a typeahead (input + dropdown + removable chips),
  mirroring the recipe filter but themed with the semantic variables.
2026-05-22 12:10:18 +02:00

345 lines
12 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 type { Difficulty } from '$types/hikes';
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);
// Phone vs. desktop viewport switch — drives which pre-rendered pose
// (`HIKES_OVERVIEW.zoom/center` vs. `.zoomNarrow/.centerNarrow`) we
// hand to Leaflet's first `setView` so it lands aligned with whichever
// static `<img>` the CSS is showing. Starts `false` to match SSR (which
// has no window); the $effect snaps it to the real value on mount and
// keeps it in sync if the user rotates / resizes across the breakpoint.
let narrowViewport = $state(false);
$effect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(max-width: 560px)');
narrowViewport = mq.matches;
const onChange = (e: MediaQueryListEvent) => {
narrowViewport = e.matches;
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
});
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 };
}
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>(),
tags: new SvelteSet<string>()
});
// 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 = [];
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;
// 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. Two width variants
ship — desktop (wide pose) and phone (narrow pose, ≤560 CSS
px). 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.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">
<HikesFilterBar
hikes={data.hikes}
{filter}
resultCount={visible.length}
totalCount={data.hikes.length}
totalKm={totals.km}
totalGain={totals.gain}
/>
{#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;
/* 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;
}
/* Wide ↔ narrow viewport swap. The narrow variant is rendered at a
* phone-sized fit, so the zoom matches what Leaflet picks at the same
* container width — without this the desktop hero would land too
* zoomed-in on phones (its pose was chosen for ~1920 CSS px). */
.hero-static-narrow { display: none; }
@media (max-width: 560px) {
.hero-static-wide { 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;
}
@media (max-width: 560px) {
.grid {
grid-template-columns: 1fr;
gap: 1rem;
}
}
</style>