feat(hikes): inline cantonal Wappen next to region label
26 public-domain coats of arms fetched once from Wikimedia Commons via scripts/download-cantons.ts and committed under static/cantons/. $lib/data/cantons.ts maps Swisstopo's free-form name (German default, French/Italian alternates for Romandie / Ticino) to the ISO code + emblem URL. Card shows an 18×22 emblem, detail page a 24×30 one — both with a drop-shadow so they read against the dark hero gradient. Unknown canton names fall back to plain text without the emblem. The downloaded SVGs are written verbatim — earlier draft prepended a provenance HTML comment but that breaks the leading `<?xml … ?>` and browsers refuse to render the image. Provenance lives in the script's CANTONS table instead.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||||
import Mountain from '@lucide/svelte/icons/mountain';
|
||||
import CalendarRange from '@lucide/svelte/icons/calendar-range';
|
||||
import { resolveCanton } from '$lib/data/cantons';
|
||||
import type { HikeManifestEntry } from '$types/hikes';
|
||||
|
||||
interface Props {
|
||||
@@ -48,6 +49,8 @@
|
||||
const days = (Date.now() - t) / 86_400_000;
|
||||
return days >= 0 && days <= 30;
|
||||
});
|
||||
|
||||
const canton = $derived(resolveCanton(hike.canton));
|
||||
</script>
|
||||
|
||||
<a class="card" href={resolve('/hikes/[slug]', { slug: hike.slug })} style="view-transition-name: hike-{hike.slug}">
|
||||
@@ -88,7 +91,22 @@
|
||||
<header class="head">
|
||||
<h2 class="title">{hike.title}</h2>
|
||||
{#if hike.region}
|
||||
<p class="region">{hike.region}{hike.canton && hike.canton !== hike.region ? `, ${hike.canton}` : ''}</p>
|
||||
<p class="region">
|
||||
{#if canton}
|
||||
<img
|
||||
class="canton-emblem"
|
||||
src={canton.emblemUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{/if}<span class="region-text"
|
||||
>{hike.region}{hike.canton && hike.canton !== hike.region
|
||||
? `, ${hike.canton}`
|
||||
: ''}</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
@@ -275,11 +293,32 @@
|
||||
}
|
||||
|
||||
.region {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.canton-emblem {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
/* Most cantonal arms are tall-rectangle shields, a couple (Schwyz,
|
||||
* Solothurn) are squarer — `contain` keeps the proportions correct
|
||||
* inside the fixed slot so a row of cards stays visually aligned. */
|
||||
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.18));
|
||||
}
|
||||
|
||||
.region-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Swiss-canton lookup. `resolveCanton(name)` takes whatever the Swisstopo
|
||||
* reverse-geocode returns (German is the default, but French/Italian names
|
||||
* surface for Romandie / Ticino hikes) and resolves it to a stable record
|
||||
* carrying the ISO code, a short label for tooltips, and the URL of the
|
||||
* pre-downloaded coat-of-arms SVG.
|
||||
*
|
||||
* The 26 SVGs live in `static/cantons/<iso-code>.svg` — fetched once by
|
||||
* `scripts/download-cantons.ts` and committed.
|
||||
*/
|
||||
|
||||
export type Canton = {
|
||||
/** ISO 3166-2:CH code, lowercase (e.g. 'ar'). */
|
||||
code: string;
|
||||
/** Canonical German name (matches the `static/cantons/` filename map). */
|
||||
name: string;
|
||||
/** Short label used in tooltips / compact UIs. */
|
||||
abbr: string;
|
||||
/** Absolute URL of the coat-of-arms SVG. */
|
||||
emblemUrl: string;
|
||||
};
|
||||
|
||||
// Tuple format keeps the file compact: [code, German name, short abbr, ...alternate names].
|
||||
// Alternates cover French/Italian renderings that Swisstopo occasionally returns
|
||||
// for cantons with multiple official languages, plus the few historic spellings.
|
||||
const CANTON_TABLE: ReadonlyArray<readonly [string, string, string, ...string[]]> = [
|
||||
['ag', 'Aargau', 'AG'],
|
||||
['ai', 'Appenzell Innerrhoden', 'AI'],
|
||||
['ar', 'Appenzell Ausserrhoden', 'AR'],
|
||||
['be', 'Bern', 'BE', 'Berne'],
|
||||
['bl', 'Basel-Landschaft', 'BL', 'Bâle-Campagne'],
|
||||
['bs', 'Basel-Stadt', 'BS', 'Bâle-Ville'],
|
||||
['fr', 'Freiburg', 'FR', 'Fribourg'],
|
||||
['ge', 'Genf', 'GE', 'Genève', 'Geneva'],
|
||||
['gl', 'Glarus', 'GL'],
|
||||
['gr', 'Graubünden', 'GR', 'Grigioni', 'Grischun', 'Grisons'],
|
||||
['ju', 'Jura', 'JU'],
|
||||
['lu', 'Luzern', 'LU', 'Lucerne'],
|
||||
['ne', 'Neuenburg', 'NE', 'Neuchâtel'],
|
||||
['nw', 'Nidwalden', 'NW'],
|
||||
['ow', 'Obwalden', 'OW'],
|
||||
['sg', 'St. Gallen', 'SG', 'Sankt Gallen', 'Saint-Gall', 'San Gallo'],
|
||||
['sh', 'Schaffhausen', 'SH', 'Schaffhouse'],
|
||||
['so', 'Solothurn', 'SO', 'Soleure'],
|
||||
['sz', 'Schwyz', 'SZ'],
|
||||
['tg', 'Thurgau', 'TG', 'Thurgovie'],
|
||||
['ti', 'Tessin', 'TI', 'Ticino'],
|
||||
['ur', 'Uri', 'UR'],
|
||||
['vd', 'Waadt', 'VD', 'Vaud'],
|
||||
['vs', 'Wallis', 'VS', 'Valais', 'Vallese'],
|
||||
['zg', 'Zug', 'ZG', 'Zoug'],
|
||||
['zh', 'Zürich', 'ZH', 'Zurich', 'Zurigo']
|
||||
];
|
||||
|
||||
// Normalise for lookup: strip accents, lowercase, collapse whitespace.
|
||||
// Lets "St. Gallen" / "Sankt Gallen" / "saint-gall" all match the same entry.
|
||||
function normalise(s: string): string {
|
||||
return s
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/gi, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const BY_NAME = new Map<string, Canton>();
|
||||
for (const [code, name, abbr, ...alts] of CANTON_TABLE) {
|
||||
const canton: Canton = {
|
||||
code,
|
||||
name,
|
||||
abbr,
|
||||
emblemUrl: `/cantons/${code}.svg`
|
||||
};
|
||||
BY_NAME.set(normalise(name), canton);
|
||||
for (const alt of alts) BY_NAME.set(normalise(alt), canton);
|
||||
// Also accept the ISO code itself (`'AR'`, `'ar'`).
|
||||
BY_NAME.set(normalise(code), canton);
|
||||
}
|
||||
|
||||
/** Resolve a free-form canton name (any official language) to a Canton
|
||||
* record. Returns null if the name doesn't match a known canton — caller
|
||||
* should fall back to plain text without the emblem. */
|
||||
export function resolveCanton(name: string | null | undefined): Canton | null {
|
||||
if (!name) return null;
|
||||
return BY_NAME.get(normalise(name)) ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user