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",
|
||||
"version": "1.79.1",
|
||||
"version": "1.80.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+74
-33
@@ -1,42 +1,63 @@
|
||||
<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.
|
||||
// Typeahead chip selector — a text field that opens a dropdown of matching
|
||||
// options, with the picked ones shown below as removable chips. Generic over
|
||||
// the value: used for free-text tags (with a leading "#") and for cantons
|
||||
// (with the coat-of-arms emblem rendered before the name). Themed with the
|
||||
// semantic variables 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}. */
|
||||
/** All selectable values, in display order. */
|
||||
options: string[];
|
||||
/** Currently-selected values. Mutated via {@link onToggle}. */
|
||||
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 open = $state(false);
|
||||
let wrapper = $state<HTMLElement>();
|
||||
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(
|
||||
inputValue.trim() === ''
|
||||
? unselected
|
||||
: unselected.filter((t) => t.toLowerCase().includes(inputValue.trim().toLowerCase()))
|
||||
const filtered = $derived.by(() => {
|
||||
const q = inputValue.trim().toLowerCase();
|
||||
if (q === '') return unselected;
|
||||
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.
|
||||
const selectedList = $derived(tags.filter((t) => selected.has(t)));
|
||||
// Selected values kept in the canonical display order rather than click order.
|
||||
const selectedList = $derived(options.filter((v) => selected.has(v)));
|
||||
|
||||
function pick(tag: string) {
|
||||
onToggle(tag);
|
||||
function pick(value: string) {
|
||||
onToggle(value);
|
||||
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();
|
||||
open = true;
|
||||
}
|
||||
@@ -44,8 +65,10 @@
|
||||
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];
|
||||
const q = inputValue.trim().toLowerCase();
|
||||
const match =
|
||||
filtered.find((v) => labelFor(v).toLowerCase() === q || v.toLowerCase() === q) ??
|
||||
filtered[0];
|
||||
if (match) pick(match);
|
||||
} else if (e.key === 'Escape') {
|
||||
if (inputValue) {
|
||||
@@ -74,18 +97,21 @@
|
||||
bind:value={inputValue}
|
||||
onfocus={() => (open = true)}
|
||||
onkeydown={onKey}
|
||||
placeholder="Schlagwort eingeben oder auswählen…"
|
||||
{placeholder}
|
||||
autocomplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls="tt-dropdown"
|
||||
aria-controls={dropdownId}
|
||||
/>
|
||||
|
||||
{#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}
|
||||
<div class="tt-dropdown" id={dropdownId}>
|
||||
{#each filtered as value (value)}
|
||||
{@const icon = iconFor?.(value)}
|
||||
<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>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -94,14 +120,17 @@
|
||||
|
||||
{#if selectedList.length > 0}
|
||||
<div class="tt-selected">
|
||||
{#each selectedList as tag (tag)}
|
||||
{#each selectedList as value (value)}
|
||||
{@const icon = iconFor?.(value)}
|
||||
<button
|
||||
type="button"
|
||||
class="tt-chip"
|
||||
onclick={() => onToggle(tag)}
|
||||
aria-label="{tag} entfernen"
|
||||
onclick={() => onToggle(value)}
|
||||
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" />
|
||||
</button>
|
||||
{/each}
|
||||
@@ -161,6 +190,9 @@
|
||||
}
|
||||
|
||||
.tt-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
@@ -189,7 +221,7 @@
|
||||
.tt-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
gap: 0.3rem;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
@@ -212,6 +244,15 @@
|
||||
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 {
|
||||
opacity: 0.6;
|
||||
font-weight: 600;
|
||||
@@ -5,7 +5,9 @@
|
||||
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 ChipTypeahead from './ChipTypeahead.svelte';
|
||||
import Toggle from '$lib/components/Toggle.svelte';
|
||||
import { resolveHikeArea, type HikeArea } from '$lib/hikes/hikeArea';
|
||||
import {
|
||||
hikeFilterBounds,
|
||||
DISTANCE_STEP,
|
||||
@@ -25,7 +27,12 @@
|
||||
maxLossM: number;
|
||||
difficulties: SvelteSet<Difficulty>;
|
||||
regions: SvelteSet<string>;
|
||||
/** Namespaced area values — canton (CH) or country (abroad). See
|
||||
* {@link resolveHikeArea}. */
|
||||
areas: SvelteSet<string>;
|
||||
tags: SvelteSet<string>;
|
||||
/** Show only hikes whose recommended season covers the current month. */
|
||||
inSeasonOnly: boolean;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -66,6 +73,22 @@
|
||||
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
|
||||
// ties. Frequency ordering surfaces broadly-applicable filters like
|
||||
// "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.
|
||||
// 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 out: Chip[] = [];
|
||||
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)
|
||||
out.push({
|
||||
key: 'dist',
|
||||
@@ -159,6 +184,15 @@
|
||||
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 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)
|
||||
out.push({ key: `t-${t}`, label: `#${t}`, clear: () => filter.tags.delete(t) });
|
||||
return out;
|
||||
@@ -176,6 +210,11 @@
|
||||
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) {
|
||||
if (filter.tags.has(t)) filter.tags.delete(t);
|
||||
else filter.tags.add(t);
|
||||
@@ -192,7 +231,9 @@
|
||||
filter.maxLossM = bounds.loss.max;
|
||||
filter.difficulties.clear();
|
||||
filter.regions.clear();
|
||||
filter.areas.clear();
|
||||
filter.tags.clear();
|
||||
filter.inSeasonOnly = false;
|
||||
}
|
||||
|
||||
// Light-dismiss: close the panel on outside click or Escape. Only wired
|
||||
@@ -229,7 +270,8 @@
|
||||
<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>
|
||||
{#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" />
|
||||
</button>
|
||||
{/each}
|
||||
@@ -253,6 +295,12 @@
|
||||
|
||||
{#if open}
|
||||
<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">
|
||||
<RangeSlider
|
||||
label="Distanz"
|
||||
@@ -328,10 +376,30 @@
|
||||
</fieldset>
|
||||
{/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}
|
||||
<fieldset>
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
@@ -428,6 +496,15 @@
|
||||
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 {
|
||||
appearance: none;
|
||||
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 { HIKES_OVERVIEW } from '$lib/data/hikes.generated';
|
||||
import { hikeFilterBounds } from '$lib/hikes/filterBounds';
|
||||
import { resolveHikeArea } from '$lib/hikes/hikeArea';
|
||||
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';
|
||||
|
||||
const { data }: PageProps = $props();
|
||||
@@ -62,7 +72,9 @@
|
||||
maxLossM: Number.POSITIVE_INFINITY,
|
||||
difficulties: new SvelteSet<Difficulty>(),
|
||||
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`)
|
||||
@@ -121,6 +133,7 @@
|
||||
|
||||
const visible = $derived.by(() => {
|
||||
const out = [];
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
for (const h of data.hikes) {
|
||||
if (h.distanceKm < filter.minDistanceKm || h.distanceKm > filter.maxDistanceKm) continue;
|
||||
const dur = h.durationMin ?? 0;
|
||||
@@ -129,6 +142,11 @@
|
||||
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;
|
||||
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
|
||||
// would shrink the listing to ~zero quickly given how few tags
|
||||
// most hikes have; OR matches how detail-page chips feel like
|
||||
|
||||
@@ -54,6 +54,11 @@ export type HikeManifestEntry = {
|
||||
region: string | null;
|
||||
canton: 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
|
||||
// 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