- {#each DIFFICULTIES as d (d)}
-
- {/each}
+ {#if open}
+
+
+ `${v} km`}
+ />
+
+ `${v} m`}
+ />
+ `${v} m`}
+ />
-
- {#if regions.length > 0}
+
+
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 {