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
+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));