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:
2026-05-19 08:44:30 +02:00
parent 2c3886296c
commit cfdd58fb18
31 changed files with 2371 additions and 3 deletions
+40 -1
View File
@@ -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;
+86
View File
@@ -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;
}