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.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.77.2",
|
||||
"version": "1.78.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import RangeSlider from './RangeSlider.svelte';
|
||||
import TagTypeahead from './TagTypeahead.svelte';
|
||||
import {
|
||||
hikeFilterBounds,
|
||||
DISTANCE_STEP,
|
||||
DURATION_STEP,
|
||||
ELEVATION_STEP
|
||||
} from '$lib/hikes/filterBounds';
|
||||
import type { Difficulty, HikeManifestEntry } from '$types/hikes';
|
||||
|
||||
export type HikesFilter = {
|
||||
minDistanceKm: number;
|
||||
maxDistanceKm: number;
|
||||
minDurationMin: number;
|
||||
maxDurationMin: number;
|
||||
minGainM: number;
|
||||
maxGainM: number;
|
||||
minLossM: number;
|
||||
maxLossM: number;
|
||||
difficulties: SvelteSet<Difficulty>;
|
||||
regions: SvelteSet<string>;
|
||||
@@ -15,16 +31,28 @@
|
||||
interface Props {
|
||||
hikes: HikeManifestEntry[];
|
||||
filter: HikesFilter;
|
||||
/** Hikes passing the current filter — shown in the bar summary. */
|
||||
resultCount: number;
|
||||
/** Total hikes before filtering. */
|
||||
totalCount: number;
|
||||
/** Summed distance / ascent over the filtered subset (already rounded). */
|
||||
totalKm: number;
|
||||
totalGain: number;
|
||||
}
|
||||
|
||||
const DIFFICULTIES: Difficulty[] = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6'];
|
||||
|
||||
const { hikes, filter }: Props = $props();
|
||||
const { hikes, filter, resultCount, totalCount, totalKm, totalGain }: Props = $props();
|
||||
|
||||
const maxDistance = $derived(Math.max(1, ...hikes.map((h) => Math.ceil(h.distanceKm))));
|
||||
const maxDuration = $derived(Math.max(60, ...hikes.map((h) => h.durationMin ?? 0)));
|
||||
const maxGain = $derived(Math.max(100, ...hikes.map((h) => h.elevationGainM)));
|
||||
const maxLoss = $derived(Math.max(100, ...hikes.map((h) => h.elevationLossM)));
|
||||
// Collapsed-by-default: the bar is just a summary + active-filter chips +
|
||||
// a trigger until the user opens the control panel. Keeps the listing's
|
||||
// vertical rhythm clean — the filters only take space when wanted.
|
||||
let open = $state(false);
|
||||
let root = $state<HTMLElement>();
|
||||
|
||||
// Range-slider track extents, derived from the data (see filterBounds.ts —
|
||||
// the same helper seeds the page's default filter state).
|
||||
const bounds = $derived(hikeFilterBounds(hikes));
|
||||
|
||||
const regions = $derived.by(() => {
|
||||
const seen: Record<string, true> = {};
|
||||
@@ -54,6 +82,90 @@
|
||||
.map(([t]) => t);
|
||||
});
|
||||
|
||||
function fmtDuration(min: number) {
|
||||
return `${Math.floor(min / 60)}h ${min % 60}m`;
|
||||
}
|
||||
|
||||
// Compact chip label for a narrowed range: "≤ hi" / "≥ lo" / "lo–hi",
|
||||
// suppressing whichever end still sits at its data bound.
|
||||
function rangeLabel(
|
||||
lo: number,
|
||||
hi: number,
|
||||
b: { min: number; max: number },
|
||||
fmt: (v: number) => string,
|
||||
unit: string
|
||||
) {
|
||||
const u = unit ? ` ${unit}` : '';
|
||||
const loNarrowed = lo > b.min;
|
||||
const hiNarrowed = hi < b.max;
|
||||
if (loNarrowed && hiNarrowed) return `${fmt(lo)}–${fmt(hi)}${u}`;
|
||||
if (hiNarrowed) return `≤ ${fmt(hi)}${u}`;
|
||||
return `≥ ${fmt(lo)}${u}`;
|
||||
}
|
||||
|
||||
// SAC trail-sign colour band — matches the card badges (T1 yellow
|
||||
// Wegweiser, T2/T3 red-white Bergweg, T4–T6 blue-white Alpinweg). Used
|
||||
// for the small colour dot on each difficulty toggle.
|
||||
function sacBand(d: Difficulty): 'yellow' | 'red' | 'blue' {
|
||||
if (d === 'T1') return 'yellow';
|
||||
if (d === 'T2' || d === 'T3') return 'red';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
// Active filters, flattened into removable chips for the collapsed bar.
|
||||
// A range counts as "active" only when narrowed below its data ceiling.
|
||||
type Chip = { key: string; label: string; clear: () => void };
|
||||
const chips = $derived.by<Chip[]>(() => {
|
||||
const out: Chip[] = [];
|
||||
const { distance, duration, gain, loss } = bounds;
|
||||
if (filter.minDistanceKm > distance.min || filter.maxDistanceKm < distance.max)
|
||||
out.push({
|
||||
key: 'dist',
|
||||
label: rangeLabel(filter.minDistanceKm, filter.maxDistanceKm, distance, (v) => `${v}`, 'km'),
|
||||
clear: () => {
|
||||
filter.minDistanceKm = distance.min;
|
||||
filter.maxDistanceKm = distance.max;
|
||||
}
|
||||
});
|
||||
if (filter.minDurationMin > duration.min || filter.maxDurationMin < duration.max)
|
||||
out.push({
|
||||
key: 'dur',
|
||||
label: rangeLabel(filter.minDurationMin, filter.maxDurationMin, duration, fmtDuration, ''),
|
||||
clear: () => {
|
||||
filter.minDurationMin = duration.min;
|
||||
filter.maxDurationMin = duration.max;
|
||||
}
|
||||
});
|
||||
if (filter.minGainM > gain.min || filter.maxGainM < gain.max)
|
||||
out.push({
|
||||
key: 'gain',
|
||||
label: `↑ ${rangeLabel(filter.minGainM, filter.maxGainM, gain, (v) => `${v}`, 'm')}`,
|
||||
clear: () => {
|
||||
filter.minGainM = gain.min;
|
||||
filter.maxGainM = gain.max;
|
||||
}
|
||||
});
|
||||
if (filter.minLossM > loss.min || filter.maxLossM < loss.max)
|
||||
out.push({
|
||||
key: 'loss',
|
||||
label: `↓ ${rangeLabel(filter.minLossM, filter.maxLossM, loss, (v) => `${v}`, 'm')}`,
|
||||
clear: () => {
|
||||
filter.minLossM = loss.min;
|
||||
filter.maxLossM = loss.max;
|
||||
}
|
||||
});
|
||||
for (const d of DIFFICULTIES)
|
||||
if (filter.difficulties.has(d))
|
||||
out.push({ key: `d-${d}`, label: d, clear: () => filter.difficulties.delete(d) });
|
||||
for (const r of filter.regions)
|
||||
out.push({ key: `r-${r}`, label: r, clear: () => filter.regions.delete(r) });
|
||||
for (const t of filter.tags)
|
||||
out.push({ key: `t-${t}`, label: `#${t}`, clear: () => filter.tags.delete(t) });
|
||||
return out;
|
||||
});
|
||||
|
||||
const activeCount = $derived(chips.length);
|
||||
|
||||
function toggleDifficulty(d: Difficulty) {
|
||||
if (filter.difficulties.has(d)) filter.difficulties.delete(d);
|
||||
else filter.difficulties.add(d);
|
||||
@@ -70,86 +182,132 @@
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filter.maxDistanceKm = maxDistance;
|
||||
filter.maxDurationMin = maxDuration;
|
||||
filter.maxGainM = maxGain;
|
||||
filter.maxLossM = maxLoss;
|
||||
filter.minDistanceKm = bounds.distance.min;
|
||||
filter.maxDistanceKm = bounds.distance.max;
|
||||
filter.minDurationMin = bounds.duration.min;
|
||||
filter.maxDurationMin = bounds.duration.max;
|
||||
filter.minGainM = bounds.gain.min;
|
||||
filter.maxGainM = bounds.gain.max;
|
||||
filter.minLossM = bounds.loss.min;
|
||||
filter.maxLossM = bounds.loss.max;
|
||||
filter.difficulties.clear();
|
||||
filter.regions.clear();
|
||||
filter.tags.clear();
|
||||
}
|
||||
|
||||
// Light-dismiss: close the panel on outside click or Escape. Only wired
|
||||
// up while open so the listeners aren't carried for the whole session.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
if (root && !root.contains(e.target as Node)) open = false;
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') open = false;
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="filter-bar">
|
||||
<div class="row">
|
||||
<label>
|
||||
<span class="label-row">
|
||||
<span>Distanz</span>
|
||||
<span class="value">≤ {filter.maxDistanceKm} km</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max={maxDistance}
|
||||
step="1"
|
||||
bind:value={filter.maxDistanceKm}
|
||||
/>
|
||||
</label>
|
||||
<div class="filter-bar" bind:this={root}>
|
||||
<p class="summary">
|
||||
<span class="count"><strong>{resultCount}</strong> von {totalCount} Touren</span>
|
||||
{#if resultCount > 0}
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<span class="stat">{totalKm.toLocaleString('de-CH')} km</span>
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<span class="stat">{totalGain.toLocaleString('de-CH')} hm</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<label>
|
||||
<span class="label-row">
|
||||
<span>Dauer</span>
|
||||
<span class="value">≤ {Math.floor(filter.maxDurationMin / 60)}h {filter.maxDurationMin % 60}m</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxDuration}
|
||||
step="15"
|
||||
bind:value={filter.maxDurationMin}
|
||||
/>
|
||||
</label>
|
||||
{#if activeCount > 0}
|
||||
<div class="active-chips" aria-label="Aktive Filter">
|
||||
{#each chips as chip (chip.key)}
|
||||
<button type="button" class="chip" onclick={chip.clear}>
|
||||
<span class="chip-label">{chip.label}</span>
|
||||
<X size={13} strokeWidth={2} aria-label="entfernen" />
|
||||
</button>
|
||||
{/each}
|
||||
<button type="button" class="clear-all" onclick={resetFilters}>Alle löschen</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label>
|
||||
<span class="label-row">
|
||||
<span>Aufstieg</span>
|
||||
<span class="value">≤ {filter.maxGainM} m</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxGain}
|
||||
step="50"
|
||||
bind:value={filter.maxGainM}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-toggle"
|
||||
class:open
|
||||
aria-expanded={open}
|
||||
aria-controls="filter-panel"
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<SlidersHorizontal size={16} strokeWidth={1.75} aria-hidden="true" />
|
||||
<span>Filter</span>
|
||||
{#if activeCount > 0}<span class="badge">{activeCount}</span>{/if}
|
||||
<ChevronDown class="chev" size={16} strokeWidth={1.75} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<label>
|
||||
<span class="label-row">
|
||||
<span>Abstieg</span>
|
||||
<span class="value">≤ {filter.maxLossM} m</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxLoss}
|
||||
step="50"
|
||||
bind:value={filter.maxLossM}
|
||||
{#if open}
|
||||
<div id="filter-panel" class="panel" transition:slide={{ duration: 200 }}>
|
||||
<div class="ranges">
|
||||
<RangeSlider
|
||||
label="Distanz"
|
||||
min={bounds.distance.min}
|
||||
max={bounds.distance.max}
|
||||
step={DISTANCE_STEP}
|
||||
bind:low={filter.minDistanceKm}
|
||||
bind:high={filter.maxDistanceKm}
|
||||
format={(v) => `${v} km`}
|
||||
/>
|
||||
<RangeSlider
|
||||
label="Dauer"
|
||||
min={bounds.duration.min}
|
||||
max={bounds.duration.max}
|
||||
step={DURATION_STEP}
|
||||
bind:low={filter.minDurationMin}
|
||||
bind:high={filter.maxDurationMin}
|
||||
format={fmtDuration}
|
||||
/>
|
||||
<RangeSlider
|
||||
label="Aufstieg"
|
||||
min={bounds.gain.min}
|
||||
max={bounds.gain.max}
|
||||
step={ELEVATION_STEP}
|
||||
bind:low={filter.minGainM}
|
||||
bind:high={filter.maxGainM}
|
||||
format={(v) => `${v} m`}
|
||||
/>
|
||||
<RangeSlider
|
||||
label="Abstieg"
|
||||
min={bounds.loss.min}
|
||||
max={bounds.loss.max}
|
||||
step={ELEVATION_STEP}
|
||||
bind:low={filter.minLossM}
|
||||
bind:high={filter.maxLossM}
|
||||
format={(v) => `${v} m`}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr class="divider" />
|
||||
|
||||
<fieldset>
|
||||
<legend>Schwierigkeit (SAC)</legend>
|
||||
<div class="pills">
|
||||
<div class="sac-grid">
|
||||
{#each DIFFICULTIES as d (d)}
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
class="sac-toggle"
|
||||
class:active={filter.difficulties.has(d)}
|
||||
aria-pressed={filter.difficulties.has(d)}
|
||||
aria-label="SAC-Schwierigkeit {d}"
|
||||
onclick={() => toggleDifficulty(d)}
|
||||
>{d}</button>
|
||||
>
|
||||
<span class="sac-marker sac-marker-{sacBand(d)}">{d}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -170,69 +328,198 @@
|
||||
</fieldset>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="reset" onclick={resetFilters}>Zurücksetzen</button>
|
||||
</div>
|
||||
|
||||
{#if tags.length > 0}
|
||||
<fieldset class="tags-fieldset">
|
||||
<fieldset>
|
||||
<legend>Schlagwörter</legend>
|
||||
<div class="pills">
|
||||
{#each tags as t (t)}
|
||||
<button
|
||||
type="button"
|
||||
class="pill pill-tag"
|
||||
class:active={filter.tags.has(t)}
|
||||
onclick={() => toggleTag(t)}
|
||||
>
|
||||
<span class="pill-tag-hash" aria-hidden="true">#</span>{t}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<TagTypeahead {tags} selected={filter.tags} onToggle={toggleTag} />
|
||||
</fieldset>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<div class="panel-foot">
|
||||
<button type="button" class="reset" onclick={resetFilters} disabled={activeCount === 0}>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filter-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.25rem;
|
||||
padding: 0.5rem 0.6rem 0.5rem 1rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
margin-top: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
.summary {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
flex: 0 1 auto;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
.count strong {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-primary);
|
||||
.stat {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
.dot {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Active filters surfaced inline so the user always sees what's narrowing
|
||||
* the listing without opening the panel; each chip removes its own facet. */
|
||||
.active-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.18rem 0.5rem 0.18rem 0.65rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 32%, var(--color-border));
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, var(--color-surface));
|
||||
}
|
||||
|
||||
.chip :global(svg) {
|
||||
opacity: 0.6;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.chip:hover :global(svg) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.clear-all {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 0.18rem 0.3rem;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.clear-all:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-toggle:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.filter-toggle.open {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.filter-toggle :global(.chev) {
|
||||
transition: rotate var(--transition-normal);
|
||||
}
|
||||
|
||||
.filter-toggle.open :global(.chev) {
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
padding: 0 0.35rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Expands in-flow on its own full-width row inside the bar, so opening it
|
||||
* pushes the card grid down (accordion) rather than overlaying it. The
|
||||
* top border separates it from the summary row; both it and the vertical
|
||||
* padding are animated by the `slide` transition. */
|
||||
.panel {
|
||||
width: 100%;
|
||||
accent-color: var(--color-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
margin-top: 0.6rem;
|
||||
padding-top: 1.1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ranges {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.1rem 1.75rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
@@ -243,15 +530,17 @@
|
||||
|
||||
legend {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
@@ -264,7 +553,8 @@
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: scale var(--transition-fast), background-color var(--transition-fast), color var(--transition-fast);
|
||||
transition: scale var(--transition-fast), background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
@@ -278,9 +568,103 @@
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Difficulty toggles render the actual SAC trail-sign markers (same shapes
|
||||
* as the hike cards): T1 yellow Wegweiser arrow, T2/T3 white-red-white
|
||||
* Bergweg, T4–T6 white-blue-white Alpinweg. No container chrome — boxing
|
||||
* the irregular arrow looked off. Selection is the sign itself "lighting
|
||||
* up": unselected signs are dimmed + desaturated, the selected ones snap
|
||||
* to full colour, scale up and lift with a shadow. */
|
||||
.sac-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.sac-toggle {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.6);
|
||||
transition: scale var(--transition-fast), opacity var(--transition-fast),
|
||||
filter var(--transition-fast);
|
||||
}
|
||||
|
||||
.sac-toggle:hover {
|
||||
opacity: 0.85;
|
||||
filter: grayscale(0.1);
|
||||
scale: 1.08;
|
||||
}
|
||||
|
||||
.sac-toggle.active {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
scale: 1.08;
|
||||
}
|
||||
|
||||
.sac-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Lift only the selected signs so they read as raised above the dimmed
|
||||
* ones. (Applied to the marker, not the toggle, so it survives the
|
||||
* toggle's `filter: none`.) */
|
||||
.sac-toggle.active .sac-marker {
|
||||
filter: drop-shadow(0 2px 5px rgb(0 0 0 / 0.35));
|
||||
}
|
||||
|
||||
.sac-marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 26px;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sac-marker-yellow {
|
||||
width: 44px;
|
||||
color: #1a1a1a;
|
||||
background: #f5a623;
|
||||
clip-path: polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%);
|
||||
/* Text sits in the rectangular left portion (arrow tip is the right 25%). */
|
||||
justify-content: flex-start;
|
||||
padding-left: 0.55rem;
|
||||
}
|
||||
|
||||
.sac-marker-red,
|
||||
.sac-marker-blue {
|
||||
width: 32px;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 1px rgb(0 0 0 / 0.45);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
.sac-marker-red {
|
||||
background: linear-gradient(to bottom, #fff 0 25%, #dc1d2a 25% 75%, #fff 75% 100%);
|
||||
}
|
||||
|
||||
.sac-marker-blue {
|
||||
background: linear-gradient(to bottom, #fff 0 25%, #2965c8 25% 75%, #fff 75% 100%);
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.reset {
|
||||
align-self: center;
|
||||
justify-self: end;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -290,31 +674,26 @@
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.reset:hover {
|
||||
.reset:hover:not(:disabled) {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
/* Tags fieldset spans the full filter-bar width — there can be many
|
||||
* (every word an author uses), and forcing it into a column with the
|
||||
* single-line region/difficulty groups makes them all unreadable. */
|
||||
.tags-fieldset {
|
||||
margin-top: 1rem;
|
||||
.reset:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pill-tag {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.2rem;
|
||||
@media (max-width: 560px) {
|
||||
.ranges {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pill-tag-hash {
|
||||
opacity: 0.55;
|
||||
font-weight: 600;
|
||||
/* Give the trigger its own line so the summary + chips aren't squeezed. */
|
||||
.summary {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.pill-tag.active .pill-tag-hash {
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
// Dual-thumb range slider: one track, two handles (lower + upper bound).
|
||||
// Custom pointer/keyboard implementation rather than two overlaid
|
||||
// <input type=range> elements — the latter lock up when both thumbs
|
||||
// coincide at an edge. Here a drag that crosses the other thumb hands off
|
||||
// to it, so the range is always adjustable.
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Track extent (data floor / ceiling). */
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
/** Current lower bound. Bindable. */
|
||||
low: number;
|
||||
/** Current upper bound. Bindable. */
|
||||
high: number;
|
||||
/** Renders a value for the readout + aria-valuetext. */
|
||||
format?: (v: number) => string;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
low = $bindable(),
|
||||
high = $bindable(),
|
||||
format = (v) => String(v)
|
||||
}: Props = $props();
|
||||
|
||||
let trackEl = $state<HTMLElement>();
|
||||
let lowThumb = $state<HTMLElement>();
|
||||
let highThumb = $state<HTMLElement>();
|
||||
let dragging = $state<null | 'low' | 'high'>(null);
|
||||
|
||||
const span = $derived(Math.max(1, max - min));
|
||||
// Clamp for display so an out-of-range initial value (e.g. ±Infinity
|
||||
// before the data defaults land) still paints a sane thumb position.
|
||||
const lowPct = $derived(((Math.min(Math.max(low, min), max) - min) / span) * 100);
|
||||
const highPct = $derived(((Math.min(Math.max(high, min), max) - min) / span) * 100);
|
||||
|
||||
function snap(v: number) {
|
||||
return Math.round(v / step) * step;
|
||||
}
|
||||
|
||||
function setLow(v: number) {
|
||||
low = Math.min(Math.max(snap(v), min), high);
|
||||
}
|
||||
|
||||
function setHigh(v: number) {
|
||||
high = Math.max(Math.min(snap(v), max), low);
|
||||
}
|
||||
|
||||
function valueFromClientX(clientX: number) {
|
||||
if (!trackEl) return min;
|
||||
const r = trackEl.getBoundingClientRect();
|
||||
const ratio = r.width > 0 ? (clientX - r.left) / r.width : 0;
|
||||
return min + Math.min(Math.max(ratio, 0), 1) * (max - min);
|
||||
}
|
||||
|
||||
// Move the active thumb; if it crosses the other one, hand the drag over so
|
||||
// dragging stays continuous instead of stalling at the collision point.
|
||||
function update(which: 'low' | 'high', raw: number) {
|
||||
const v = Math.min(Math.max(snap(raw), min), max);
|
||||
if (which === 'low') {
|
||||
if (v > high) {
|
||||
dragging = 'high';
|
||||
highThumb?.focus();
|
||||
setHigh(v);
|
||||
} else setLow(v);
|
||||
} else {
|
||||
if (v < low) {
|
||||
dragging = 'low';
|
||||
lowThumb?.focus();
|
||||
setLow(v);
|
||||
} else setHigh(v);
|
||||
}
|
||||
}
|
||||
|
||||
function onTrackPointerDown(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
const v = valueFromClientX(e.clientX);
|
||||
const which: 'low' | 'high' = Math.abs(v - low) <= Math.abs(v - high) ? 'low' : 'high';
|
||||
dragging = which;
|
||||
(which === 'low' ? lowThumb : highThumb)?.focus();
|
||||
trackEl?.setPointerCapture(e.pointerId);
|
||||
update(which, v);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
update(dragging, valueFromClientX(e.clientX));
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging = null;
|
||||
}
|
||||
|
||||
function onThumbKey(e: KeyboardEvent, which: 'low' | 'high') {
|
||||
const big = step * 10;
|
||||
let delta = 0;
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
delta = step;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
delta = -step;
|
||||
break;
|
||||
case 'PageUp':
|
||||
delta = big;
|
||||
break;
|
||||
case 'PageDown':
|
||||
delta = -big;
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
if (which === 'low') setLow(min);
|
||||
else setHigh(low);
|
||||
return;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
if (which === 'low') setLow(high);
|
||||
else setHigh(max);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (which === 'low') setLow(low + delta);
|
||||
else setHigh(high + delta);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rs">
|
||||
<div class="rs-head">
|
||||
<span class="rs-label">{label}</span>
|
||||
<span class="rs-value">{format(low)} – {format(high)}</span>
|
||||
</div>
|
||||
<div
|
||||
class="rs-track"
|
||||
role="group"
|
||||
aria-label={label}
|
||||
bind:this={trackEl}
|
||||
onpointerdown={onTrackPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<div class="rs-rail"></div>
|
||||
<div class="rs-fill" style="left: {lowPct}%; right: {100 - highPct}%;"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="rs-thumb"
|
||||
class:active={dragging === 'low'}
|
||||
bind:this={lowThumb}
|
||||
style="left: {lowPct}%"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="{label} Minimum"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={high}
|
||||
aria-valuenow={low}
|
||||
aria-valuetext={format(low)}
|
||||
onkeydown={(e) => onThumbKey(e, 'low')}
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="rs-thumb"
|
||||
class:active={dragging === 'high'}
|
||||
bind:this={highThumb}
|
||||
style="left: {highPct}%"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="{label} Maximum"
|
||||
aria-valuemin={low}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={high}
|
||||
aria-valuetext={format(high)}
|
||||
onkeydown={(e) => onThumbKey(e, 'high')}
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.rs-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rs-label {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.rs-value {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rs-track {
|
||||
position: relative;
|
||||
height: 1.25rem;
|
||||
touch-action: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rs-rail,
|
||||
.rs-fill {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 0.3rem;
|
||||
transform: translateY(-50%);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.rs-rail {
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.rs-fill {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.rs-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: grab;
|
||||
appearance: none;
|
||||
transition: scale var(--transition-fast);
|
||||
}
|
||||
|
||||
.rs-thumb:hover {
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
.rs-thumb:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.rs-thumb.active {
|
||||
cursor: grabbing;
|
||||
scale: 1.15;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
// Tag selection by typeahead — same interaction as the recipe TagFilter:
|
||||
// a text field that opens a dropdown of matching tags, with the picked
|
||||
// tags shown below as removable chips. Themed with the semantic variables
|
||||
// (the recipe original hardcodes Nord values) so it fits the filter panel
|
||||
// in both colour schemes.
|
||||
import type { SvelteSet } from 'svelte/reactivity';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
/** All selectable tags, in display order (frequency-sorted upstream). */
|
||||
tags: string[];
|
||||
/** Currently-selected tags. Mutated via {@link onToggle}. */
|
||||
selected: SvelteSet<string>;
|
||||
onToggle: (tag: string) => void;
|
||||
}
|
||||
|
||||
const { tags, selected, onToggle }: Props = $props();
|
||||
|
||||
let inputValue = $state('');
|
||||
let open = $state(false);
|
||||
let wrapper = $state<HTMLElement>();
|
||||
let inputEl = $state<HTMLInputElement>();
|
||||
|
||||
const unselected = $derived(tags.filter((t) => !selected.has(t)));
|
||||
|
||||
const filtered = $derived(
|
||||
inputValue.trim() === ''
|
||||
? unselected
|
||||
: unselected.filter((t) => t.toLowerCase().includes(inputValue.trim().toLowerCase()))
|
||||
);
|
||||
|
||||
// Selected tags kept in the canonical display order rather than click order.
|
||||
const selectedList = $derived(tags.filter((t) => selected.has(t)));
|
||||
|
||||
function pick(tag: string) {
|
||||
onToggle(tag);
|
||||
inputValue = '';
|
||||
// Keep the field focused so several tags can be added in a row.
|
||||
inputEl?.focus();
|
||||
open = true;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const value = inputValue.trim().toLowerCase();
|
||||
const match = filtered.find((t) => t.toLowerCase() === value) ?? filtered[0];
|
||||
if (match) pick(match);
|
||||
} else if (e.key === 'Escape') {
|
||||
if (inputValue) {
|
||||
inputValue = '';
|
||||
} else {
|
||||
open = false;
|
||||
inputEl?.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close when focus leaves the whole widget (click-away / tab-out), but stay
|
||||
// open while moving between the input and its dropdown chips.
|
||||
function onFocusOut(e: FocusEvent) {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !wrapper?.contains(next)) open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tt" bind:this={wrapper} onfocusout={onFocusOut}>
|
||||
<div class="tt-field">
|
||||
<input
|
||||
class="tt-input"
|
||||
type="text"
|
||||
bind:this={inputEl}
|
||||
bind:value={inputValue}
|
||||
onfocus={() => (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}
|
||||
<div class="tt-dropdown" id="tt-dropdown">
|
||||
{#each filtered as tag (tag)}
|
||||
<button type="button" class="tt-option" onclick={() => pick(tag)}>
|
||||
<span class="tt-hash" aria-hidden="true">#</span>{tag}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedList.length > 0}
|
||||
<div class="tt-selected">
|
||||
{#each selectedList as tag (tag)}
|
||||
<button
|
||||
type="button"
|
||||
class="tt-chip"
|
||||
onclick={() => onToggle(tag)}
|
||||
aria-label="{tag} entfernen"
|
||||
>
|
||||
<span class="tt-hash" aria-hidden="true">#</span>{tag}
|
||||
<X size={13} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tt-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tt-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tt-input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.tt-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tt-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.3rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.tt-option {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: scale var(--transition-fast), background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tt-option:hover {
|
||||
scale: 1.05;
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tt-selected {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tt-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.5rem 0.25rem 0.7rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-on-primary);
|
||||
background: var(--color-primary);
|
||||
border: 1px solid var(--color-primary);
|
||||
transition: scale var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tt-chip:hover {
|
||||
scale: 1.05;
|
||||
background: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.tt-chip :global(svg) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tt-hash {
|
||||
opacity: 0.6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tt-chip .tt-hash {
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -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<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>(),
|
||||
@@ -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 @@
|
||||
</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} />
|
||||
<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>
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user