diff --git a/package.json b/package.json index de6cbf71..59b177e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.74.1", + "version": "1.75.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/download-cantons.ts b/scripts/download-cantons.ts new file mode 100644 index 00000000..df2b8d54 --- /dev/null +++ b/scripts/download-cantons.ts @@ -0,0 +1,126 @@ +/** + * One-shot fetch of the 26 Swiss cantonal coats of arms (Wappen) from + * Wikimedia Commons into `static/cantons/.svg`. Files are + * public-domain Swiss official insignia (PD-CH-coat-of-arms); we keep + * the source filename in a header comment for traceability. + * + * Re-run with `pnpm exec vite-node scripts/download-cantons.ts` to refresh + * any missing files. Existing files are left alone — the cantonal arms + * don't change. + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +type CantonEntry = { + code: string; // ISO 3166-2:CH (lowercase for filename) + commonsFile: string; // Commons filename WITHOUT the `File:` prefix +}; + +// Names follow the "Wappen matt.svg" convention used across +// almost all cantons on Commons. The handful of exceptions (Basel-Stadt, +// Basel-Landschaft, the two Appenzells) are spelt out explicitly. If a +// fetch returns 404 the script logs the failure and continues so the +// remaining cantons still land. +const CANTONS: CantonEntry[] = [ + { code: 'ag', commonsFile: 'Wappen Aargau matt.svg' }, + { code: 'ai', commonsFile: 'Wappen Appenzell Innerrhoden matt.svg' }, + { code: 'ar', commonsFile: 'Wappen Appenzell Ausserrhoden matt.svg' }, + { code: 'be', commonsFile: 'Wappen Bern matt.svg' }, + { code: 'bl', commonsFile: 'Wappen Basel-Landschaft matt.svg' }, + { code: 'bs', commonsFile: 'Wappen Basel-Stadt matt.svg' }, + { code: 'fr', commonsFile: 'Wappen Freiburg matt.svg' }, + { code: 'ge', commonsFile: 'Wappen Genf matt.svg' }, + { code: 'gl', commonsFile: 'Wappen Glarus matt.svg' }, + { code: 'gr', commonsFile: 'Wappen Graubünden matt.svg' }, + { code: 'ju', commonsFile: 'Wappen Jura matt.svg' }, + { code: 'lu', commonsFile: 'Wappen Luzern matt.svg' }, + { code: 'ne', commonsFile: 'Wappen Neuenburg matt.svg' }, + { code: 'nw', commonsFile: 'Wappen Nidwalden matt.svg' }, + { code: 'ow', commonsFile: 'Wappen Obwalden matt.svg' }, + { code: 'sg', commonsFile: 'Wappen St. Gallen matt.svg' }, + { code: 'sh', commonsFile: 'Wappen Schaffhausen matt.svg' }, + { code: 'so', commonsFile: 'Wappen Solothurn matt.svg' }, + { code: 'sz', commonsFile: 'Wappen Schwyz matt.svg' }, + { code: 'tg', commonsFile: 'Wappen Thurgau matt.svg' }, + { code: 'ti', commonsFile: 'Wappen Tessin matt.svg' }, + { code: 'ur', commonsFile: 'Wappen Uri matt.svg' }, + { code: 'vd', commonsFile: 'Wappen Waadt matt.svg' }, + { code: 'vs', commonsFile: 'Wappen Wallis matt.svg' }, + { code: 'zg', commonsFile: 'Wappen Zug matt.svg' }, + { code: 'zh', commonsFile: 'Wappen Zürich matt.svg' } +]; + +const OUT_DIR = path.resolve(process.cwd(), 'static', 'cantons'); +const UA = 'bocken-homepage cantons-downloader (https://bocken.org)'; + +async function exists(p: string): Promise { + try { await fs.access(p); return true; } catch { return false; } +} + +/** Resolve a Commons `File:Foo.svg` to its actual upload.wikimedia.org URL + * via the public API. Returns null on failure (typo in filename, etc.). */ +async function resolveCommonsUrl(file: string): Promise { + const url = + 'https://commons.wikimedia.org/w/api.php' + + '?action=query&format=json&prop=imageinfo&iiprop=url' + + '&titles=' + encodeURIComponent('File:' + file); + const res = await fetch(url, { headers: { 'User-Agent': UA } }); + if (!res.ok) return null; + const json = (await res.json()) as { + query?: { pages?: Record }> }; + }; + const pages = json.query?.pages; + if (!pages) return null; + for (const page of Object.values(pages)) { + const u = page.imageinfo?.[0]?.url; + if (u) return u; + } + return null; +} + +async function downloadCanton(c: CantonEntry): Promise<'ok' | 'cached' | 'failed'> { + const outPath = path.join(OUT_DIR, `${c.code}.svg`); + if (await exists(outPath)) return 'cached'; + + const url = await resolveCommonsUrl(c.commonsFile); + if (!url) { + console.warn(`[cantons] ${c.code}: could not resolve Commons file "${c.commonsFile}"`); + return 'failed'; + } + + const res = await fetch(url, { headers: { 'User-Agent': UA } }); + if (!res.ok) { + console.warn(`[cantons] ${c.code}: HTTP ${res.status} fetching ${url}`); + return 'failed'; + } + const body = await res.text(); + // Don't prepend anything: most of these files start with an `` + // declaration, and that MUST be the very first thing in the file or + // strict XML parsers (including browsers loading via ``) reject + // the document. Provenance is tracked in the CANTONS table above + // instead — keep it out of the file bytes. + await fs.writeFile(outPath, body); + return 'ok'; +} + +async function main() { + await fs.mkdir(OUT_DIR, { recursive: true }); + + let ok = 0, cached = 0, failed = 0; + for (const c of CANTONS) { + const r = await downloadCanton(c); + if (r === 'ok') ok++; + else if (r === 'cached') cached++; + else failed++; + if (r === 'ok') console.log(`[cantons] ${c.code}: downloaded`); + else if (r === 'cached') console.log(`[cantons] ${c.code}: cached`); + } + console.log(`[cantons] done — ${ok} downloaded, ${cached} cached, ${failed} failed`); + if (failed > 0) process.exitCode = 1; +} + +main().catch((err) => { + console.error('[cantons] fatal:', err); + process.exit(1); +}); diff --git a/src/lib/components/hikes/HikeCard.svelte b/src/lib/components/hikes/HikeCard.svelte index 6f4451d9..8ec4cd64 100644 --- a/src/lib/components/hikes/HikeCard.svelte +++ b/src/lib/components/hikes/HikeCard.svelte @@ -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)); @@ -88,7 +91,22 @@

{hike.title}

{#if hike.region} -

{hike.region}{hike.canton && hike.canton !== hike.region ? `, ${hike.canton}` : ''}

+

+ {#if canton} + + {/if}{hike.region}{hike.canton && hike.canton !== hike.region + ? `, ${hike.canton}` + : ''} +

{/if}
@@ -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; diff --git a/src/lib/data/cantons.ts b/src/lib/data/cantons.ts new file mode 100644 index 00000000..7b505cd8 --- /dev/null +++ b/src/lib/data/cantons.ts @@ -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/.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 = [ + ['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(); +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; +} diff --git a/src/routes/hikes/[slug]/+page.svelte b/src/routes/hikes/[slug]/+page.svelte index a1750218..5e506e29 100644 --- a/src/routes/hikes/[slug]/+page.svelte +++ b/src/routes/hikes/[slug]/+page.svelte @@ -16,6 +16,7 @@ import CalendarRange from '@lucide/svelte/icons/calendar-range'; import Download from '@lucide/svelte/icons/download'; import { buildGpx, type GpxWritePoint } from '$lib/gpx'; + import { resolveCanton } from '$lib/data/cantons'; import type { HikeTrackPoint } from '$types/hikes'; import type { PageProps } from './$types'; @@ -49,6 +50,8 @@ return () => mq.removeEventListener('change', onChange); }); + const canton = $derived(resolveCanton(hike.canton)); + const heroPose = $derived.by(() => { if ( narrowViewport && @@ -334,7 +337,21 @@

{hike.title}

{#if hike.region}

- {hike.region}{hike.canton && hike.canton !== hike.region ? `, ${hike.canton}` : ''} + {#if canton} + + {/if} + + {hike.region}{hike.canton && hike.canton !== hike.region + ? `, ${hike.canton}` + : ''} +

{/if} @@ -571,11 +588,28 @@ } .region { + display: flex; + align-items: center; + gap: 0.5rem; margin: 0.2rem 0 0; opacity: 0.9; text-shadow: 0 1px 4px rgb(0 0 0 / 0.45); } + .canton-emblem { + flex: 0 0 auto; + width: 24px; + height: 30px; + object-fit: contain; + /* Drop shadow keeps the emblem readable on the gradient overlay + * (which only goes dark from ~50 % down). */ + filter: drop-shadow(0 1px 3px rgb(0 0 0 / 0.5)); + } + + .region-text { + min-width: 0; + } + .metrics { display: flex; flex-wrap: wrap; diff --git a/static/cantons/ag.svg b/static/cantons/ag.svg new file mode 100644 index 00000000..05662489 --- /dev/null +++ b/static/cantons/ag.svg @@ -0,0 +1,15 @@ + + +Wappen Aargau + + + + + + + + + + + + \ No newline at end of file diff --git a/static/cantons/ai.svg b/static/cantons/ai.svg new file mode 100644 index 00000000..c13d2c79 --- /dev/null +++ b/static/cantons/ai.svg @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/ar.svg b/static/cantons/ar.svg new file mode 100644 index 00000000..388b8eb9 --- /dev/null +++ b/static/cantons/ar.svg @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/be.svg b/static/cantons/be.svg new file mode 100644 index 00000000..0bd16447 --- /dev/null +++ b/static/cantons/be.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/bl.svg b/static/cantons/bl.svg new file mode 100644 index 00000000..d42cc32d --- /dev/null +++ b/static/cantons/bl.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/bs.svg b/static/cantons/bs.svg new file mode 100644 index 00000000..eaf28d1c --- /dev/null +++ b/static/cantons/bs.svg @@ -0,0 +1,19 @@ + + + +Wappen Stadt Basel + + + diff --git a/static/cantons/fr.svg b/static/cantons/fr.svg new file mode 100644 index 00000000..a874f867 --- /dev/null +++ b/static/cantons/fr.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/static/cantons/ge.svg b/static/cantons/ge.svg new file mode 100644 index 00000000..db2ee44e --- /dev/null +++ b/static/cantons/ge.svg @@ -0,0 +1,112 @@ + + + +image/svg+xml + + + + + + + + + + \ No newline at end of file diff --git a/static/cantons/gl.svg b/static/cantons/gl.svg new file mode 100644 index 00000000..1a039b3b --- /dev/null +++ b/static/cantons/gl.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/gr.svg b/static/cantons/gr.svg new file mode 100644 index 00000000..e7660a5d --- /dev/null +++ b/static/cantons/gr.svg @@ -0,0 +1,15 @@ + + +Wappen Graubünden + + + + + + + + + + + + \ No newline at end of file diff --git a/static/cantons/ju.svg b/static/cantons/ju.svg new file mode 100644 index 00000000..7b9e814c --- /dev/null +++ b/static/cantons/ju.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/lu.svg b/static/cantons/lu.svg new file mode 100644 index 00000000..6db17420 --- /dev/null +++ b/static/cantons/lu.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/ne.svg b/static/cantons/ne.svg new file mode 100644 index 00000000..2866b736 --- /dev/null +++ b/static/cantons/ne.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/nw.svg b/static/cantons/nw.svg new file mode 100644 index 00000000..710a4f19 --- /dev/null +++ b/static/cantons/nw.svg @@ -0,0 +1,11 @@ + + +Wappen Nidwalden + + + + + + + + \ No newline at end of file diff --git a/static/cantons/ow.svg b/static/cantons/ow.svg new file mode 100644 index 00000000..04de5551 --- /dev/null +++ b/static/cantons/ow.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/sg.svg b/static/cantons/sg.svg new file mode 100644 index 00000000..dbdbf188 --- /dev/null +++ b/static/cantons/sg.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/cantons/sh.svg b/static/cantons/sh.svg new file mode 100644 index 00000000..820b57eb --- /dev/null +++ b/static/cantons/sh.svg @@ -0,0 +1,53 @@ + + +Wappen Schaffhausen + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/cantons/so.svg b/static/cantons/so.svg new file mode 100644 index 00000000..63bc2d4d --- /dev/null +++ b/static/cantons/so.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/sz.svg b/static/cantons/sz.svg new file mode 100644 index 00000000..2c9f6391 --- /dev/null +++ b/static/cantons/sz.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/tg.svg b/static/cantons/tg.svg new file mode 100644 index 00000000..6d32f1c3 --- /dev/null +++ b/static/cantons/tg.svg @@ -0,0 +1,334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/ti.svg b/static/cantons/ti.svg new file mode 100644 index 00000000..1aad7519 --- /dev/null +++ b/static/cantons/ti.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/ur.svg b/static/cantons/ur.svg new file mode 100644 index 00000000..ebe999c6 --- /dev/null +++ b/static/cantons/ur.svg @@ -0,0 +1,39 @@ + + +Wappen Uri + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/cantons/vd.svg b/static/cantons/vd.svg new file mode 100644 index 00000000..18d04806 --- /dev/null +++ b/static/cantons/vd.svg @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/vs.svg b/static/cantons/vs.svg new file mode 100644 index 00000000..3ef8e87e --- /dev/null +++ b/static/cantons/vs.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/zg.svg b/static/cantons/zg.svg new file mode 100644 index 00000000..48be4fe5 --- /dev/null +++ b/static/cantons/zg.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/cantons/zh.svg b/static/cantons/zh.svg new file mode 100644 index 00000000..bd85f645 --- /dev/null +++ b/static/cantons/zh.svg @@ -0,0 +1,15 @@ + + + + + + + + + +