feat(hikes): in-season toggle + unified canton/country filter
Add two filters to the /hikes filter panel: - "Nur Touren in der aktuellen Saison" toggle — keeps only hikes whose recommended season window covers the current month (year-wrap aware; hikes without a window count as year-round). - "Kanton / Land" — a typeahead that abstracts the hike's area over the border: Swiss hikes group by canton (coat-of-arms), hikes abroad by country (flag). Generalised the tag typeahead into ChipTypeahead (optional icon + label mapping) and reused it for both tags and areas. Supporting bits: countries.ts (ISO/name → flag), hikeArea.ts (the canton-or-country resolver, namespaced so codes can't collide), prepared flag SVGs for CH/DE/IT/AT/FR, and an optional `country` field on the hike manifest type (populated by the build script; the app falls back to canton for Swiss hikes until a rebuild).
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.79.1",
|
"version": "1.80.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+74
-33
@@ -1,42 +1,63 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Tag selection by typeahead — same interaction as the recipe TagFilter:
|
// Typeahead chip selector — a text field that opens a dropdown of matching
|
||||||
// a text field that opens a dropdown of matching tags, with the picked
|
// options, with the picked ones shown below as removable chips. Generic over
|
||||||
// tags shown below as removable chips. Themed with the semantic variables
|
// the value: used for free-text tags (with a leading "#") and for cantons
|
||||||
// (the recipe original hardcodes Nord values) so it fits the filter panel
|
// (with the coat-of-arms emblem rendered before the name). Themed with the
|
||||||
// in both colour schemes.
|
// semantic variables so it fits the filter panel in both colour schemes.
|
||||||
import type { SvelteSet } from 'svelte/reactivity';
|
import type { SvelteSet } from 'svelte/reactivity';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** All selectable tags, in display order (frequency-sorted upstream). */
|
/** All selectable values, in display order. */
|
||||||
tags: string[];
|
options: string[];
|
||||||
/** Currently-selected tags. Mutated via {@link onToggle}. */
|
/** Currently-selected values. Mutated via {@link onToggle}. */
|
||||||
selected: SvelteSet<string>;
|
selected: SvelteSet<string>;
|
||||||
onToggle: (tag: string) => void;
|
onToggle: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Prefix each value with a "#" (tag style). */
|
||||||
|
hash?: boolean;
|
||||||
|
/** Optional icon URL rendered before each value (e.g. canton emblem). */
|
||||||
|
iconFor?: (value: string) => string | undefined;
|
||||||
|
/** Display label for a value (defaults to the value itself). */
|
||||||
|
labelFor?: (value: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tags, selected, onToggle }: Props = $props();
|
const {
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
placeholder = 'Eingeben oder auswählen…',
|
||||||
|
hash = false,
|
||||||
|
iconFor,
|
||||||
|
labelFor = (v) => v
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Unique per instance — two of these live in the panel at once (tags +
|
||||||
|
// cantons), so a shared id would be a duplicate.
|
||||||
|
const dropdownId = `tt-dd-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
let inputValue = $state('');
|
let inputValue = $state('');
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let wrapper = $state<HTMLElement>();
|
let wrapper = $state<HTMLElement>();
|
||||||
let inputEl = $state<HTMLInputElement>();
|
let inputEl = $state<HTMLInputElement>();
|
||||||
|
|
||||||
const unselected = $derived(tags.filter((t) => !selected.has(t)));
|
const unselected = $derived(options.filter((v) => !selected.has(v)));
|
||||||
|
|
||||||
const filtered = $derived(
|
const filtered = $derived.by(() => {
|
||||||
inputValue.trim() === ''
|
const q = inputValue.trim().toLowerCase();
|
||||||
? unselected
|
if (q === '') return unselected;
|
||||||
: unselected.filter((t) => t.toLowerCase().includes(inputValue.trim().toLowerCase()))
|
return unselected.filter(
|
||||||
|
(v) => labelFor(v).toLowerCase().includes(q) || v.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Selected tags kept in the canonical display order rather than click order.
|
// Selected values kept in the canonical display order rather than click order.
|
||||||
const selectedList = $derived(tags.filter((t) => selected.has(t)));
|
const selectedList = $derived(options.filter((v) => selected.has(v)));
|
||||||
|
|
||||||
function pick(tag: string) {
|
function pick(value: string) {
|
||||||
onToggle(tag);
|
onToggle(value);
|
||||||
inputValue = '';
|
inputValue = '';
|
||||||
// Keep the field focused so several tags can be added in a row.
|
// Keep the field focused so several can be added in a row.
|
||||||
inputEl?.focus();
|
inputEl?.focus();
|
||||||
open = true;
|
open = true;
|
||||||
}
|
}
|
||||||
@@ -44,8 +65,10 @@
|
|||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const value = inputValue.trim().toLowerCase();
|
const q = inputValue.trim().toLowerCase();
|
||||||
const match = filtered.find((t) => t.toLowerCase() === value) ?? filtered[0];
|
const match =
|
||||||
|
filtered.find((v) => labelFor(v).toLowerCase() === q || v.toLowerCase() === q) ??
|
||||||
|
filtered[0];
|
||||||
if (match) pick(match);
|
if (match) pick(match);
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
if (inputValue) {
|
if (inputValue) {
|
||||||
@@ -74,18 +97,21 @@
|
|||||||
bind:value={inputValue}
|
bind:value={inputValue}
|
||||||
onfocus={() => (open = true)}
|
onfocus={() => (open = true)}
|
||||||
onkeydown={onKey}
|
onkeydown={onKey}
|
||||||
placeholder="Schlagwort eingeben oder auswählen…"
|
{placeholder}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls="tt-dropdown"
|
aria-controls={dropdownId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if open && filtered.length > 0}
|
{#if open && filtered.length > 0}
|
||||||
<div class="tt-dropdown" id="tt-dropdown">
|
<div class="tt-dropdown" id={dropdownId}>
|
||||||
{#each filtered as tag (tag)}
|
{#each filtered as value (value)}
|
||||||
<button type="button" class="tt-option" onclick={() => pick(tag)}>
|
{@const icon = iconFor?.(value)}
|
||||||
<span class="tt-hash" aria-hidden="true">#</span>{tag}
|
<button type="button" class="tt-option" onclick={() => pick(value)}>
|
||||||
|
{#if icon}<img class="tt-emblem" src={icon} alt="" aria-hidden="true" />{/if}
|
||||||
|
{#if hash}<span class="tt-hash" aria-hidden="true">#</span>{/if}
|
||||||
|
{labelFor(value)}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -94,14 +120,17 @@
|
|||||||
|
|
||||||
{#if selectedList.length > 0}
|
{#if selectedList.length > 0}
|
||||||
<div class="tt-selected">
|
<div class="tt-selected">
|
||||||
{#each selectedList as tag (tag)}
|
{#each selectedList as value (value)}
|
||||||
|
{@const icon = iconFor?.(value)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tt-chip"
|
class="tt-chip"
|
||||||
onclick={() => onToggle(tag)}
|
onclick={() => onToggle(value)}
|
||||||
aria-label="{tag} entfernen"
|
aria-label={`${labelFor(value)} entfernen`}
|
||||||
>
|
>
|
||||||
<span class="tt-hash" aria-hidden="true">#</span>{tag}
|
{#if icon}<img class="tt-emblem" src={icon} alt="" aria-hidden="true" />{/if}
|
||||||
|
{#if hash}<span class="tt-hash" aria-hidden="true">#</span>{/if}
|
||||||
|
{labelFor(value)}
|
||||||
<X size={13} strokeWidth={2} aria-hidden="true" />
|
<X size={13} strokeWidth={2} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -161,6 +190,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tt-option {
|
.tt-option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -189,7 +221,7 @@
|
|||||||
.tt-chip {
|
.tt-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.3rem;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -212,6 +244,15 @@
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Canton coat-of-arms — tall shield, kept proportional in a fixed slot. */
|
||||||
|
.tt-emblem {
|
||||||
|
width: 13px;
|
||||||
|
height: 16px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
.tt-hash {
|
.tt-hash {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import RangeSlider from './RangeSlider.svelte';
|
import RangeSlider from './RangeSlider.svelte';
|
||||||
import TagTypeahead from './TagTypeahead.svelte';
|
import ChipTypeahead from './ChipTypeahead.svelte';
|
||||||
|
import Toggle from '$lib/components/Toggle.svelte';
|
||||||
|
import { resolveHikeArea, type HikeArea } from '$lib/hikes/hikeArea';
|
||||||
import {
|
import {
|
||||||
hikeFilterBounds,
|
hikeFilterBounds,
|
||||||
DISTANCE_STEP,
|
DISTANCE_STEP,
|
||||||
@@ -25,7 +27,12 @@
|
|||||||
maxLossM: number;
|
maxLossM: number;
|
||||||
difficulties: SvelteSet<Difficulty>;
|
difficulties: SvelteSet<Difficulty>;
|
||||||
regions: SvelteSet<string>;
|
regions: SvelteSet<string>;
|
||||||
|
/** Namespaced area values — canton (CH) or country (abroad). See
|
||||||
|
* {@link resolveHikeArea}. */
|
||||||
|
areas: SvelteSet<string>;
|
||||||
tags: SvelteSet<string>;
|
tags: SvelteSet<string>;
|
||||||
|
/** Show only hikes whose recommended season covers the current month. */
|
||||||
|
inSeasonOnly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -66,6 +73,22 @@
|
|||||||
return out.sort((a, b) => a.localeCompare(b));
|
return out.sort((a, b) => a.localeCompare(b));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Geographic areas present in the data: a Swiss hike contributes its canton,
|
||||||
|
// a hike abroad its country. Deduped by namespaced value; cantons listed
|
||||||
|
// first (alphabetical), then countries (alphabetical).
|
||||||
|
const areaList = $derived.by(() => {
|
||||||
|
const map = new Map<string, HikeArea>();
|
||||||
|
for (const h of hikes) {
|
||||||
|
const a = resolveHikeArea(h.canton, h.country);
|
||||||
|
if (a && !map.has(a.value)) map.set(a.value, a);
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) =>
|
||||||
|
a.kind === b.kind ? a.label.localeCompare(b.label) : a.kind === 'canton' ? -1 : 1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const areaValues = $derived(areaList.map((a) => a.value));
|
||||||
|
const areaByValue = $derived(new Map(areaList.map((a) => [a.value, a])));
|
||||||
|
|
||||||
// Tags sorted by usage frequency (most-used first), alphabetical for
|
// Tags sorted by usage frequency (most-used first), alphabetical for
|
||||||
// ties. Frequency ordering surfaces broadly-applicable filters like
|
// ties. Frequency ordering surfaces broadly-applicable filters like
|
||||||
// "winter" or "easy" at the head of the list, where they're most
|
// "winter" or "easy" at the head of the list, where they're most
|
||||||
@@ -114,10 +137,12 @@
|
|||||||
|
|
||||||
// Active filters, flattened into removable chips for the collapsed bar.
|
// Active filters, flattened into removable chips for the collapsed bar.
|
||||||
// A range counts as "active" only when narrowed below its data ceiling.
|
// A range counts as "active" only when narrowed below its data ceiling.
|
||||||
type Chip = { key: string; label: string; clear: () => void };
|
type Chip = { key: string; label: string; icon?: string; clear: () => void };
|
||||||
const chips = $derived.by<Chip[]>(() => {
|
const chips = $derived.by<Chip[]>(() => {
|
||||||
const out: Chip[] = [];
|
const out: Chip[] = [];
|
||||||
const { distance, duration, gain, loss } = bounds;
|
const { distance, duration, gain, loss } = bounds;
|
||||||
|
if (filter.inSeasonOnly)
|
||||||
|
out.push({ key: 'season', label: 'In Saison', clear: () => (filter.inSeasonOnly = false) });
|
||||||
if (filter.minDistanceKm > distance.min || filter.maxDistanceKm < distance.max)
|
if (filter.minDistanceKm > distance.min || filter.maxDistanceKm < distance.max)
|
||||||
out.push({
|
out.push({
|
||||||
key: 'dist',
|
key: 'dist',
|
||||||
@@ -159,6 +184,15 @@
|
|||||||
out.push({ key: `d-${d}`, label: d, clear: () => filter.difficulties.delete(d) });
|
out.push({ key: `d-${d}`, label: d, clear: () => filter.difficulties.delete(d) });
|
||||||
for (const r of filter.regions)
|
for (const r of filter.regions)
|
||||||
out.push({ key: `r-${r}`, label: r, clear: () => filter.regions.delete(r) });
|
out.push({ key: `r-${r}`, label: r, clear: () => filter.regions.delete(r) });
|
||||||
|
for (const value of filter.areas) {
|
||||||
|
const a = areaByValue.get(value);
|
||||||
|
out.push({
|
||||||
|
key: `a-${value}`,
|
||||||
|
label: a?.label ?? value,
|
||||||
|
icon: a?.iconUrl,
|
||||||
|
clear: () => filter.areas.delete(value)
|
||||||
|
});
|
||||||
|
}
|
||||||
for (const t of filter.tags)
|
for (const t of filter.tags)
|
||||||
out.push({ key: `t-${t}`, label: `#${t}`, clear: () => filter.tags.delete(t) });
|
out.push({ key: `t-${t}`, label: `#${t}`, clear: () => filter.tags.delete(t) });
|
||||||
return out;
|
return out;
|
||||||
@@ -176,6 +210,11 @@
|
|||||||
else filter.regions.add(r);
|
else filter.regions.add(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleArea(value: string) {
|
||||||
|
if (filter.areas.has(value)) filter.areas.delete(value);
|
||||||
|
else filter.areas.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
function toggleTag(t: string) {
|
function toggleTag(t: string) {
|
||||||
if (filter.tags.has(t)) filter.tags.delete(t);
|
if (filter.tags.has(t)) filter.tags.delete(t);
|
||||||
else filter.tags.add(t);
|
else filter.tags.add(t);
|
||||||
@@ -192,7 +231,9 @@
|
|||||||
filter.maxLossM = bounds.loss.max;
|
filter.maxLossM = bounds.loss.max;
|
||||||
filter.difficulties.clear();
|
filter.difficulties.clear();
|
||||||
filter.regions.clear();
|
filter.regions.clear();
|
||||||
|
filter.areas.clear();
|
||||||
filter.tags.clear();
|
filter.tags.clear();
|
||||||
|
filter.inSeasonOnly = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Light-dismiss: close the panel on outside click or Escape. Only wired
|
// Light-dismiss: close the panel on outside click or Escape. Only wired
|
||||||
@@ -229,7 +270,8 @@
|
|||||||
<div class="active-chips" aria-label="Aktive Filter">
|
<div class="active-chips" aria-label="Aktive Filter">
|
||||||
{#each chips as chip (chip.key)}
|
{#each chips as chip (chip.key)}
|
||||||
<button type="button" class="chip" onclick={chip.clear}>
|
<button type="button" class="chip" onclick={chip.clear}>
|
||||||
<span class="chip-label">{chip.label}</span>
|
{#if chip.icon}<img class="chip-emblem" src={chip.icon} alt="" aria-hidden="true" />{/if}<span
|
||||||
|
class="chip-label">{chip.label}</span>
|
||||||
<X size={13} strokeWidth={2} aria-label="entfernen" />
|
<X size={13} strokeWidth={2} aria-label="entfernen" />
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -253,6 +295,12 @@
|
|||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div id="filter-panel" class="panel" transition:slide={{ duration: 200 }}>
|
<div id="filter-panel" class="panel" transition:slide={{ duration: 200 }}>
|
||||||
|
<div class="season-row">
|
||||||
|
<Toggle bind:checked={filter.inSeasonOnly} label="Nur Touren in der aktuellen Saison" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider" />
|
||||||
|
|
||||||
<div class="ranges">
|
<div class="ranges">
|
||||||
<RangeSlider
|
<RangeSlider
|
||||||
label="Distanz"
|
label="Distanz"
|
||||||
@@ -328,10 +376,30 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if areaList.length > 0}
|
||||||
|
<fieldset>
|
||||||
|
<legend>Kanton / Land</legend>
|
||||||
|
<ChipTypeahead
|
||||||
|
options={areaValues}
|
||||||
|
selected={filter.areas}
|
||||||
|
onToggle={toggleArea}
|
||||||
|
placeholder="Kanton oder Land eingeben oder auswählen…"
|
||||||
|
iconFor={(value) => areaByValue.get(value)?.iconUrl}
|
||||||
|
labelFor={(value) => areaByValue.get(value)?.label ?? value}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if tags.length > 0}
|
{#if tags.length > 0}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Schlagwörter</legend>
|
<legend>Schlagwörter</legend>
|
||||||
<TagTypeahead {tags} selected={filter.tags} onToggle={toggleTag} />
|
<ChipTypeahead
|
||||||
|
options={tags}
|
||||||
|
selected={filter.tags}
|
||||||
|
onToggle={toggleTag}
|
||||||
|
hash
|
||||||
|
placeholder="Schlagwort eingeben oder auswählen…"
|
||||||
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -428,6 +496,15 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Canton coat-of-arms inside an active-filter chip. */
|
||||||
|
.chip-emblem {
|
||||||
|
width: 12px;
|
||||||
|
height: 15px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
.clear-all {
|
.clear-all {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Country lookup for hikes outside Switzerland. `resolveCountry(name)` takes
|
||||||
|
* an ISO 3166-1 alpha-2 code (what the build-time geocoder emits) or a
|
||||||
|
* free-form country name (any common language) and resolves it to a stable
|
||||||
|
* record with the code, a German display name, and the flag SVG URL.
|
||||||
|
*
|
||||||
|
* The flags live in `static/countries/<code>.svg` (lowercase ISO code).
|
||||||
|
* Swiss hikes are grouped by canton instead — see `resolveHikeArea`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Country = {
|
||||||
|
/** ISO 3166-1 alpha-2 code, uppercase (e.g. 'DE'). */
|
||||||
|
code: string;
|
||||||
|
/** German display name. */
|
||||||
|
name: string;
|
||||||
|
/** Absolute URL of the flag SVG. */
|
||||||
|
flagUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// [code, German name, ...alternate names / spellings the geocoder may return].
|
||||||
|
const COUNTRY_TABLE: ReadonlyArray<readonly [string, string, ...string[]]> = [
|
||||||
|
['CH', 'Schweiz', 'Switzerland', 'Suisse', 'Svizzera', 'Svizra'],
|
||||||
|
['DE', 'Deutschland', 'Germany', 'Allemagne', 'Germania'],
|
||||||
|
['IT', 'Italien', 'Italy', 'Italia', 'Italie'],
|
||||||
|
['AT', 'Österreich', 'Austria', 'Autriche', 'Oesterreich'],
|
||||||
|
['FR', 'Frankreich', 'France', 'Francia'],
|
||||||
|
['LI', 'Liechtenstein']
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalise(s: string): string {
|
||||||
|
return s
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/gi, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const BY_NAME = new Map<string, Country>();
|
||||||
|
for (const [code, name, ...alts] of COUNTRY_TABLE) {
|
||||||
|
const country: Country = { code, name, flagUrl: `/countries/${code.toLowerCase()}.svg` };
|
||||||
|
BY_NAME.set(normalise(name), country);
|
||||||
|
BY_NAME.set(normalise(code), country);
|
||||||
|
for (const alt of alts) BY_NAME.set(normalise(alt), country);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve an ISO code or free-form country name to a Country, or null if
|
||||||
|
* unknown (only the prepared Alpine-region countries are listed). */
|
||||||
|
export function resolveCountry(name: string | null | undefined): Country | null {
|
||||||
|
if (!name) return null;
|
||||||
|
return BY_NAME.get(normalise(name)) ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { resolveCanton } from '$lib/data/cantons';
|
||||||
|
import { resolveCountry } from '$lib/data/countries';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geographic grouping for a hike, abstracted over the border: a Swiss hike is
|
||||||
|
* grouped by its canton (with the coat-of-arms), a hike abroad by its country
|
||||||
|
* (with the flag). Canton wins when present, so existing Swiss manifest
|
||||||
|
* entries keep working even before the build adds a `country`.
|
||||||
|
*/
|
||||||
|
export type HikeArea = {
|
||||||
|
/** Namespaced so canton and country codes can't collide in a filter set. */
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
iconUrl: string;
|
||||||
|
kind: 'canton' | 'country';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveHikeArea(
|
||||||
|
canton: string | null | undefined,
|
||||||
|
country: string | null | undefined
|
||||||
|
): HikeArea | null {
|
||||||
|
const c = resolveCanton(canton);
|
||||||
|
if (c) return { value: `canton:${c.code}`, label: c.name, iconUrl: c.emblemUrl, kind: 'canton' };
|
||||||
|
const k = resolveCountry(country);
|
||||||
|
if (k) return { value: `country:${k.code}`, label: k.name, iconUrl: k.flagUrl, kind: 'country' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -8,7 +8,17 @@
|
|||||||
import Seo from '$lib/components/Seo.svelte';
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
import { HIKES_OVERVIEW } from '$lib/data/hikes.generated';
|
import { HIKES_OVERVIEW } from '$lib/data/hikes.generated';
|
||||||
import { hikeFilterBounds } from '$lib/hikes/filterBounds';
|
import { hikeFilterBounds } from '$lib/hikes/filterBounds';
|
||||||
|
import { resolveHikeArea } from '$lib/hikes/hikeArea';
|
||||||
import type { Difficulty } from '$types/hikes';
|
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. 11–3 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';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
const { data }: PageProps = $props();
|
const { data }: PageProps = $props();
|
||||||
@@ -62,7 +72,9 @@
|
|||||||
maxLossM: Number.POSITIVE_INFINITY,
|
maxLossM: Number.POSITIVE_INFINITY,
|
||||||
difficulties: new SvelteSet<Difficulty>(),
|
difficulties: new SvelteSet<Difficulty>(),
|
||||||
regions: new SvelteSet<string>(),
|
regions: new SvelteSet<string>(),
|
||||||
tags: 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`)
|
// Tag deep-link: arrival from a detail-page tag chip (`/hikes?tag=winter`)
|
||||||
@@ -121,6 +133,7 @@
|
|||||||
|
|
||||||
const visible = $derived.by(() => {
|
const visible = $derived.by(() => {
|
||||||
const out = [];
|
const out = [];
|
||||||
|
const currentMonth = new Date().getMonth() + 1;
|
||||||
for (const h of data.hikes) {
|
for (const h of data.hikes) {
|
||||||
if (h.distanceKm < filter.minDistanceKm || h.distanceKm > filter.maxDistanceKm) continue;
|
if (h.distanceKm < filter.minDistanceKm || h.distanceKm > filter.maxDistanceKm) continue;
|
||||||
const dur = h.durationMin ?? 0;
|
const dur = h.durationMin ?? 0;
|
||||||
@@ -129,6 +142,11 @@
|
|||||||
if (h.elevationLossM < filter.minLossM || h.elevationLossM > filter.maxLossM) continue;
|
if (h.elevationLossM < filter.minLossM || h.elevationLossM > filter.maxLossM) continue;
|
||||||
if (filter.difficulties.size > 0 && !filter.difficulties.has(h.difficulty)) 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.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
|
// Multi-tag = OR (a hike matching ANY selected tag is shown). AND
|
||||||
// would shrink the listing to ~zero quickly given how few tags
|
// would shrink the listing to ~zero quickly given how few tags
|
||||||
// most hikes have; OR matches how detail-page chips feel like
|
// most hikes have; OR matches how detail-page chips feel like
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ export type HikeManifestEntry = {
|
|||||||
region: string | null;
|
region: string | null;
|
||||||
canton: string | null;
|
canton: string | null;
|
||||||
municipality: string | null;
|
municipality: string | null;
|
||||||
|
/** ISO 3166-1 alpha-2 country code (e.g. 'CH', 'DE'), detected at build
|
||||||
|
* time from the centroid. Swiss hikes are grouped by `canton`; hikes
|
||||||
|
* abroad fall back to this. Optional so pre-existing manifest entries
|
||||||
|
* (built before the field existed) still type-check. */
|
||||||
|
country?: string | null;
|
||||||
|
|
||||||
// Recommended hiking-season window, 1-12 (Jan-Dec). When start > end the
|
// Recommended hiking-season window, 1-12 (Jan-Dec). When start > end the
|
||||||
// window wraps the new year (e.g. 11–3 for a winter route). Absent /
|
// window wraps the new year (e.g. 11–3 for a winter route). Absent /
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="#ed2939"/><rect width="3" height="0.6667" y="0.6667" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 164 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#d52b1e"/><rect x="13" y="6" width="6" height="20" fill="#fff"/><rect x="6" y="13" width="20" height="6" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 220 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3"><rect width="5" height="3" fill="#ffce00"/><rect width="5" height="2" fill="#d00"/><rect width="5" height="1" fill="#000"/></svg>
|
||||||
|
After Width: | Height: | Size: 188 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="#ef4135"/><rect width="2" height="2" fill="#fff"/><rect width="1" height="2" fill="#0055a4"/></svg>
|
||||||
|
After Width: | Height: | Size: 191 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="#ce2b37"/><rect width="2" height="2" fill="#fff"/><rect width="1" height="2" fill="#009246"/></svg>
|
||||||
|
After Width: | Height: | Size: 191 B |
Reference in New Issue
Block a user