feat(hikes): use SAC-tier colours for the detail-page trail

Live and static heroes on the detail page were rendered in Nord red,
while the same trail on the /hikes overview map already used the
high-contrast SAC palette (orange/red/blue). The mismatch made the
detail trail look muted against the Pixelkarte; the overview's choice
also doubles as a navigational hint ("the orange trail you saw on
the map is this one").

Introduce $lib/data/sacColors as the single source of truth so
HikeMap, HikesOverviewMap, and the build-side static renderer all
pull the same palette. Bump HERO_RENDER_VERSION to v6 so stale
Nord-red static heroes get re-rendered on the next build.
This commit is contained in:
2026-05-19 10:27:31 +02:00
parent 706dedbdc5
commit a1aa722512
6 changed files with 75 additions and 47 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.75.4",
"version": "1.75.5",
"private": true,
"type": "module",
"scripts": {
+20 -25
View File
@@ -28,6 +28,7 @@ import {
} from '../src/lib/server/gpx.js';
import { simplifyTrack } from '../src/lib/server/simplifyTrack.js';
import { computeStaticMapPose, renderOverviewMap, renderStaticMap } from './staticHikeMap.js';
import { sacTrailColor, SAC_TRAIL_COLOR } from '../src/lib/data/sacColors.js';
import type {
Difficulty,
HikeManifestEntry,
@@ -565,9 +566,10 @@ const HERO_HEIGHT = 2400;
// fly-to-fit animation on top.
const HERO_FIT_WIDTH = 1920;
const HERO_FIT_HEIGHT = 640;
// Nord red — same accent the live HikeMap uses for its polyline, so the
// fade-over from static to interactive looks continuous.
const HERO_TRAIL_COLOR = '#bf616a';
// Per-hike trail colour is picked from the SAC-tier palette in
// `$lib/data/sacColors` at render time — every static hero matches the
// live polyline colour and the overview-map polyline for the same hike,
// so the fade-over from static to interactive looks continuous.
// Photo-badge fill, border + icon-stroke colours per UI theme. Matches
// the live HikeMap's `.hike-photo-marker .badge`:
// background: var(--color-primary) → Nord10 light / Nord8 dark
@@ -585,7 +587,8 @@ const HERO_BADGE_ICON_DARK = '#2e3440';
// Bumped whenever the static-map renderer's visual output changes (icons,
// stroke widths, marker shapes, ...) so the per-hike hash invalidates and
// existing files get re-rendered on the next build.
const HERO_RENDER_VERSION = 5;
// v6: per-hike trail colour switched from Nord red to SAC-tier palette.
const HERO_RENDER_VERSION = 6;
// Narrow-viewport variant for phones (≤ 560 px CSS width). Same renderer,
// but the pose is picked for a phone-sized container so the auto-fit zoom
@@ -627,18 +630,6 @@ const HERO_VARIANT_SPECS: ReadonlyArray<{
}
];
// SAC-tier polyline colours for the overview hero. Must stay in sync with
// the `SAC_COLOR` map in `HikesOverviewMap.svelte` so the static hero's
// trails look identical to the live ones.
const OVERVIEW_SAC_COLOR: Record<Difficulty, string> = {
T1: '#f5a623',
T2: '#dc1d2a',
T3: '#dc1d2a',
T4: '#2965c8',
T5: '#2965c8',
T6: '#2965c8'
};
// Padding + max-zoom match the live overview map's
// `fitBounds(..., { padding: [32, 32], maxZoom: 13 })` so the static lands
// at the same pose Leaflet will fit to. fitHeight matches the page's
@@ -681,7 +672,7 @@ async function processOverview(
.filter((h) => h.previewPolyline && h.previewPolyline.length >= 2)
.map((h) => ({
points: h.previewPolyline,
color: OVERVIEW_SAC_COLOR[h.difficulty] ?? '#5e81ac'
color: SAC_TRAIL_COLOR[h.difficulty] ?? '#5e81ac'
}));
if (lines.length === 0) return undefined;
@@ -817,10 +808,12 @@ async function processHero(
slug: string,
track: GpxPoint[],
bbox: [number, number, number, number],
imagePoints: ImagePoint[]
imagePoints: ImagePoint[],
difficulty: Difficulty
): Promise<Partial<Record<HeroVariant, HeroVariantResult>> | undefined> {
if (track.length < 2) return undefined;
const trailColor = sacTrailColor(difficulty);
const polyline: Array<[number, number]> = track.map((p) => [p.lat, p.lng]);
// Public photo markers only — the hero is rendered once and served to
// everyone, including logged-out viewers, so private positions must
@@ -861,7 +854,7 @@ async function processHero(
h: spec.height,
fw: spec.fitWidth,
fh: spec.fitHeight,
color: HERO_TRAIL_COLOR,
color: trailColor,
poly: polyline,
photos: photoMarkers,
fill: fillColor,
@@ -883,7 +876,7 @@ async function processHero(
const ok = await renderStaticMap({
pose,
polyline,
color: HERO_TRAIL_COLOR,
color: trailColor,
outputPath: outPath,
width: spec.width,
height: spec.height,
@@ -1079,13 +1072,19 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
if (r.point) imagePoints.push(r.point);
}
// Difficulty is hoisted from the manifest assembly below because the
// hero renderer needs it to pick the SAC-tier trail colour.
const difficulty = (typeof fm.difficulty === 'string' && VALID_DIFFICULTIES.includes(fm.difficulty as Difficulty))
? (fm.difficulty as Difficulty)
: 'T1';
// Per-route icon + pre-rendered hero map — handled here (before cleanup)
// so their outNames join `keepFiles.images` and survive the orphan sweep,
// while previous-build `icon.<oldhash>.*` / `hero.<oldhash>.*` files
// (different hash, not in keepFiles) get removed automatically.
const [iconResult, heroResult] = await Promise.all([
processIcon(slug, hikeDir),
processHero(slug, track, bbox, imagePoints)
processHero(slug, track, bbox, imagePoints, difficulty)
]);
if (iconResult) keepFiles.images.add(iconResult.outName);
if (heroResult) {
@@ -1146,10 +1145,6 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
await fs.writeFile(trackFile, trackJson);
console.log(`[build-hikes:${slug}] wrote track.${trackHash}.json (${trackJson.length} bytes)`);
const difficulty = (typeof fm.difficulty === 'string' && VALID_DIFFICULTIES.includes(fm.difficulty as Difficulty))
? (fm.difficulty as Difficulty)
: 'T1';
const date = typeof fm.date === 'string'
? fm.date
: (typeof fm.date === 'number' ? new Date(fm.date).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10));
+13 -6
View File
@@ -29,6 +29,11 @@
* finished loading — i.e. the map is visually complete. The detail
* page uses this to fade out the SSR-rendered static hero. */
onReady?: () => void;
/** Polyline colour. Defaults to Nord red. Callers set this to the
* SAC-tier colour so the live trail matches the colour of the same
* route on the /hikes overview map (orange for T1, red for T2/T3,
* blue for T4T6). */
trackColor?: string;
}
const {
@@ -37,7 +42,8 @@
showPrivate = false,
initialCenter,
initialZoom,
onReady
onReady,
trackColor
}: Props = $props();
// User-location toggle moved inside the map UI. localStorage-persisted so
@@ -209,12 +215,13 @@
});
});
// Canvas-rendered polylines can't resolve CSS custom properties, so read
// the trail color from the document at mount time. Nord red contrasts
// strongly against both the schematic map and the aerial imagery.
// Canvas-rendered polylines can't resolve CSS custom properties,
// so the caller hands us a literal colour. Falls back to Nord red
// for any caller that hasn't been updated yet.
const trailColor =
getComputedStyle(document.documentElement).getPropertyValue('--red').trim() ||
'#bf616a';
trackColor ??
(getComputedStyle(document.documentElement).getPropertyValue('--red').trim() ||
'#bf616a');
const polyline = L.polyline(latLngs, {
color: trailColor,
@@ -2,7 +2,8 @@
import type { Attachment } from 'svelte/attachments';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { HikeManifestEntry, Difficulty } from '$types/hikes';
import { sacTrailColor } from '$lib/data/sacColors';
import type { HikeManifestEntry } from '$types/hikes';
import Map from '@lucide/svelte/icons/map';
import Satellite from '@lucide/svelte/icons/satellite';
import Landmark from '@lucide/svelte/icons/landmark';
@@ -27,18 +28,6 @@
const { hikes, initialCenter, initialZoom, onReady }: Props = $props();
// Per-tier polyline colour, matching the painted-marker scheme on the
// SAC badges. Canvas-rendered polylines can't resolve CSS variables,
// so the values are hard-coded — keep in sync with HikeCard.svelte.
const SAC_COLOR: Record<Difficulty, string> = {
T1: '#f5a623',
T2: '#dc1d2a',
T3: '#dc1d2a',
T4: '#2965c8',
T5: '#2965c8',
T6: '#2965c8'
};
const SWISSTOPO_FARBE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg';
const SWISSTOPO_IMAGE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg';
const SWISSTOPO_DUFOUR = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hiks-dufour/default/current/3857/{z}/{x}/{y}.png';
@@ -197,7 +186,7 @@
for (const hike of hikes) {
if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue;
const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]);
const color = SAC_COLOR[hike.difficulty] ?? '#5e81ac';
const color = sacTrailColor(hike.difficulty);
const poly = L.polyline(latLngs, {
color,
weight: 4,
+34
View File
@@ -0,0 +1,34 @@
import type { Difficulty } from '$types/hikes';
/**
* SAC-tier trail colour, used as the polyline colour for every per-hike
* track rendering (overview map, detail-page live + static heroes).
*
* T1 orange — yellow Wegweiser trails (Wanderwege)
* T2/T3 red — white-red-white Bergwanderwege
* T4-T6 blue — white-blue-white Alpinwanderwege
*
* Single source of truth — keep the build script and every Svelte
* component pointed here so a re-tune lands in one place. Values are
* saturated on purpose to contrast strongly against the Pixelkarte's
* desaturated greens / browns; tweak the build-side `HERO_RENDER_VERSION`
* if these values change so stale static heroes get invalidated.
*/
export const SAC_TRAIL_COLOR: Record<Difficulty, string> = {
T1: '#f5a623',
T2: '#dc1d2a',
T3: '#dc1d2a',
T4: '#2965c8',
T5: '#2965c8',
T6: '#2965c8'
};
/** Fallback when a hike's difficulty isn't a known SAC tier (defensive —
* the manifest type only allows valid tiers, but a stale build could leak
* an unexpected string here). */
export const SAC_TRAIL_COLOR_DEFAULT = '#bf616a';
export function sacTrailColor(difficulty: Difficulty | string | null | undefined): string {
if (!difficulty) return SAC_TRAIL_COLOR_DEFAULT;
return SAC_TRAIL_COLOR[difficulty as Difficulty] ?? SAC_TRAIL_COLOR_DEFAULT;
}
+4 -1
View File
@@ -17,6 +17,7 @@
import Download from '@lucide/svelte/icons/download';
import { buildGpx, type GpxWritePoint } from '$lib/gpx';
import { resolveCanton } from '$lib/data/cantons';
import { sacTrailColor } from '$lib/data/sacColors';
import type { HikeTrackPoint } from '$types/hikes';
import type { PageProps } from './$types';
@@ -51,6 +52,7 @@
});
const canton = $derived(resolveCanton(hike.canton));
const trackColor = $derived(sacTrailColor(hike.difficulty));
// Publish date formatted in long German for the meta footer
// (matches the hike's `date: YYYY-MM-DD` frontmatter format).
@@ -336,6 +338,7 @@
{track}
imagePoints={visibleImagePoints}
showPrivate
{trackColor}
initialCenter={heroPose?.center}
initialZoom={heroPose?.zoom}
onReady={() => (heroMapReady = true)}
@@ -451,7 +454,7 @@
<section class="scroll-area">
<aside class="trail-col">
{#if track && track.length > 0}
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate />
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate {trackColor} />
<ElevationProfile {track} />
{/if}
</aside>