From 896e42f5d91cab3528548fab259e9b6c14ff47b8 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 22 May 2026 12:10:18 +0200 Subject: [PATCH] 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. --- package.json | 2 +- .../components/hikes/HikesFilterBar.svelte | 689 ++++++++++++++---- src/lib/components/hikes/RangeSlider.svelte | 270 +++++++ src/lib/components/hikes/TagTypeahead.svelte | 223 ++++++ src/lib/hikes/filterBounds.ts | 52 ++ src/routes/hikes/+page.svelte | 99 +-- 6 files changed, 1110 insertions(+), 225 deletions(-) create mode 100644 src/lib/components/hikes/RangeSlider.svelte create mode 100644 src/lib/components/hikes/TagTypeahead.svelte create mode 100644 src/lib/hikes/filterBounds.ts diff --git a/package.json b/package.json index dae8cb4f..f6ca8d3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.77.2", + "version": "1.78.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/hikes/HikesFilterBar.svelte b/src/lib/components/hikes/HikesFilterBar.svelte index daefcb36..16297f55 100644 --- a/src/lib/components/hikes/HikesFilterBar.svelte +++ b/src/lib/components/hikes/HikesFilterBar.svelte @@ -1,11 +1,27 @@ - + diff --git a/src/lib/components/hikes/RangeSlider.svelte b/src/lib/components/hikes/RangeSlider.svelte new file mode 100644 index 00000000..47b87b45 --- /dev/null +++ b/src/lib/components/hikes/RangeSlider.svelte @@ -0,0 +1,270 @@ + + +
+
+ {label} + {format(low)} – {format(high)} +
+
+
+
+ + +
+
+ + diff --git a/src/lib/components/hikes/TagTypeahead.svelte b/src/lib/components/hikes/TagTypeahead.svelte new file mode 100644 index 00000000..a605e3fb --- /dev/null +++ b/src/lib/components/hikes/TagTypeahead.svelte @@ -0,0 +1,223 @@ + + +
+
+ (open = true)} + onkeydown={onKey} + placeholder="Schlagwort eingeben oder auswählen…" + autocomplete="off" + role="combobox" + aria-expanded={open} + aria-controls="tt-dropdown" + /> + + {#if open && filtered.length > 0} +
+ {#each filtered as tag (tag)} + + {/each} +
+ {/if} +
+ + {#if selectedList.length > 0} +
+ {#each selectedList as tag (tag)} + + {/each} +
+ {/if} +
+ + diff --git a/src/lib/hikes/filterBounds.ts b/src/lib/hikes/filterBounds.ts new file mode 100644 index 00000000..6e560d48 --- /dev/null +++ b/src/lib/hikes/filterBounds.ts @@ -0,0 +1,52 @@ +import type { HikeManifestEntry } from '$types/hikes'; + +// Shared min/max derivation for the hikes range filters, so the slider track +// extents (HikesFilterBar) and the page's default filter state (+page.svelte) +// always agree — otherwise a thumb could sit off-track or a phantom "active" +// chip could appear on first paint. + +export type Bounds = { min: number; max: number }; + +export const DISTANCE_STEP = 1; +export const DURATION_STEP = 15; +export const ELEVATION_STEP = 50; + +/** Data floor/ceiling for a metric, snapped outward to whole `step` units so + * the slider ends land on clean values. Always returns min < max. */ +export function alignBounds(values: number[], step: number): Bounds { + if (values.length === 0) return { min: 0, max: step }; + const lo = Math.min(...values); + const hi = Math.max(...values); + const min = Math.floor(lo / step) * step; + let max = Math.ceil(hi / step) * step; + if (max <= min) max = min + step; + return { min, max }; +} + +export type HikeFilterBounds = { + distance: Bounds; + duration: Bounds; + gain: Bounds; + loss: Bounds; +}; + +export function hikeFilterBounds(hikes: HikeManifestEntry[]): HikeFilterBounds { + return { + distance: alignBounds( + hikes.map((h) => h.distanceKm), + DISTANCE_STEP + ), + duration: alignBounds( + hikes.map((h) => h.durationMin ?? 0), + DURATION_STEP + ), + gain: alignBounds( + hikes.map((h) => h.elevationGainM), + ELEVATION_STEP + ), + loss: alignBounds( + hikes.map((h) => h.elevationLossM), + ELEVATION_STEP + ) + }; +} diff --git a/src/routes/hikes/+page.svelte b/src/routes/hikes/+page.svelte index 69051784..dfbab9e4 100644 --- a/src/routes/hikes/+page.svelte +++ b/src/routes/hikes/+page.svelte @@ -7,6 +7,7 @@ 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'; @@ -51,9 +52,13 @@ // subset, which then locked the filter to that one hike until the // next navigation cycle). const filter = $state({ + 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(), regions: new SvelteSet(), @@ -102,20 +107,26 @@ $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)); + 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.maxDistanceKm) continue; - if ((h.durationMin ?? 0) > filter.maxDurationMin) continue; - if (h.elevationGainM > filter.maxGainM) continue; - if (h.elevationLossM > filter.maxLossM) continue; + 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 @@ -198,19 +209,14 @@
- - - + {#if visible.length === 0}

Keine Wanderung entspricht den aktuellen Filtern.

@@ -305,54 +311,9 @@ 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; + /* Breathing room between the full-bleed hero map and the filter bar. */ + .below-hero { + margin-top: 2rem; } .grid {