feat(hikes): pre-rendered static hero map with smooth Leaflet handover

Each hike now ships two SSR-friendly hero images (light + dark theme),
composited at build time from Swisstopo tiles plus an SVG overlay of the
trail polyline, start/end markers, and per-photo camera badges. The
detail page renders the right variant immediately at first paint, then
hands over to live Leaflet without visible jumps.

Renderer (scripts/staticHikeMap.ts):
- Parallel tile fetcher with on-disk cache (scripts/.cache/swisstopo-
  tiles/) for re-build idempotency.
- `computeStaticMapPose` picks the zoom + centre Leaflet's fitBounds
  would land on at a reference 1920x640 viewport, so the static frames
  the full route on every typical desktop hero.
- Canvas rendered at 3840x2400 — large enough to fully cover ultrawide
  / 4K displays at native pixel size, so `object-fit: none` keeps the
  trail pixel-aligned with Leaflet's tile pane.
- SVG overlay: trail in Nord red, start dot Nord green, end dot Nord
  red, Lucide `camera` icon inside each photo badge. Photo badge
  fill / border / icon-stroke colours are passed per theme so light and
  dark variants match the live `.hike-photo-marker .badge` styling
  exactly (Nord10/Nord8 fill, Nord6/Nord1 border, white/Nord0 icon
  stroke). Map tiles themselves are identical across themes — no naive
  invert (it mangles the Pixelkarte palette).
- Public photo markers only — private positions are filtered out so
  they don't leak in the SSR image.

Build wiring (scripts/build-hikes.ts):
- `processHero` renders both variants in parallel, hashes inputs per
  theme, skips on cache hit. Output filenames carry the content hash so
  changes invalidate cleanly via the existing orphan sweep.
- `HikeManifestEntry` gains `heroMapUrlLight`, `heroMapUrlDark`,
  `heroMapZoom`, `heroMapCenter`.

Detail page (src/routes/hikes/[slug]/+page.svelte):
- Reserves the hero box height up front (kills CLS).
- Renders both `<img>` tags; CSS picks the right one via `data-theme`
  with `prefers-color-scheme` as the fallback.
- `object-fit: none; object-position: center` so the image displays at
  native pixel size, perfectly aligned with Leaflet's tile rendering.
- `isolation: isolate` on the hero gives Leaflet's z-index:200+ panes
  a stacking context so they can't bleed over the sticky nav.

HikeMap (src/lib/components/hikes/HikeMap.svelte):
- New `initialCenter` / `initialZoom` props — when set, the map opens
  with `setView` at the static hero's pose instead of `fitBounds`.
- New `onReady` callback — fires after the post-fly-to-bounds tile
  batch finishes loading (or a 350 ms safety timeout), letting the
  detail page fade the static out onto fully-painted tiles instead of
  onto a brief grey gap.
- Sequence: render static -> Leaflet `setView` to match -> first tile
  load -> `flyToBounds(track)` to the natural fit -> wait for new
  tiles -> fade static out.
This commit is contained in:
2026-05-18 23:38:24 +02:00
parent f3d16d5187
commit fd2d8a58d9
6 changed files with 675 additions and 13 deletions
+171 -5
View File
@@ -27,6 +27,7 @@ import {
type GpxPoint
} from '../src/lib/server/gpx.js';
import { simplifyTrack } from '../src/lib/server/simplifyTrack.js';
import { computeStaticMapPose, renderStaticMap } from './staticHikeMap.js';
import type {
Difficulty,
HikeManifestEntry,
@@ -54,7 +55,7 @@ const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'hikes.generated.ts')
const ELEV_SMOOTH_WINDOW = 5; // moving-average window for altitude denoising
const ELEV_MIN_STEP_M = 3; // discard altitude deltas below this
const PREVIEW_POLYLINE_MAX_POINTS = 30;
const PREVIEW_POLYLINE_MAX_POINTS = 150;
const IMAGE_WIDTHS = [480, 960, 1600] as const;
const IMAGE_THUMBNAIL_WIDTH = 240; // popup thumbnail for map markers
const MANIFEST_WARN_BYTES = 200_000;
@@ -541,6 +542,155 @@ async function processIcon(slug: string, hikeDir: string): Promise<{ url: string
return { url: `/hikes/${slug}/images/${outName}`, outName };
}
// ---------------------------------------------------------------------------
// Pre-rendered hero map (static Swisstopo composite + polyline overlay).
// See `scripts/staticHikeMap.ts` for the renderer; this helper just hashes
// inputs, picks an output filename, and skips when the file already exists.
// ---------------------------------------------------------------------------
// Rendered well beyond any expected viewport width so the image, displayed
// with `object-fit: none`, covers ultrawide / 4K displays without falling
// back to upscale. The bigger canvas surrounds the bbox with extra map
// context — wider viewports just see more of it, narrower viewports see
// less, and the bbox itself is always pixel-aligned with Leaflet's view.
const HERO_WIDTH = 3840;
const HERO_HEIGHT = 2400;
// Zoom-selection reference. Matches the typical desktop hero display size
// (max clamp height = 640 px, full-width up to ~1920 on common monitors)
// so the static image picks the same integer zoom Leaflet's `fitBounds`
// would pick at the live container — meaning the full route is visible on
// the static at every common desktop viewport, no zoom-out animation
// needed once the live map takes over. Narrower viewports still get the
// 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';
// 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
// border: var(--color-surface) → Nord6 light / Nord1 dark
// color: var(--color-text-on-primary) → white on the light
// theme's mid-blue primary, Nord0 on the dark theme's
// light-blue primary (which has too little contrast
// against pure white).
const HERO_BADGE_FILL_LIGHT = '#5e81ac';
const HERO_BADGE_FILL_DARK = '#88c0d0';
const HERO_BADGE_BORDER_LIGHT = '#eceff4';
const HERO_BADGE_BORDER_DARK = '#3b4252';
const HERO_BADGE_ICON_LIGHT = '#ffffff';
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;
async function processHero(
slug: string,
track: GpxPoint[],
bbox: [number, number, number, number],
imagePoints: ImagePoint[]
): Promise<
| {
lightUrl: string;
lightOutName: string;
darkUrl: string;
darkOutName: string;
zoom: number;
center: [number, number];
}
| undefined
> {
if (track.length < 2) return undefined;
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
// not be burned in.
const photoMarkers = imagePoints
.filter((ip) => ip.visibility !== 'private')
.map((ip) => ({ lat: ip.lat, lng: ip.lng }));
// Pose (zoom + centre + canvas origin) is shared by both theme variants
// so they align pixel-perfectly. Computed once up-front; renders below
// reuse it. `fitWidth × fitHeight` pin the chosen zoom to what
// Leaflet's `fitBounds` picks on a typical desktop hero, so the full
// route is visible inside the static image even though the rendered
// canvas is much larger.
const pose = computeStaticMapPose({
bbox,
width: HERO_WIDTH,
height: HERO_HEIGHT,
fitWidth: HERO_FIT_WIDTH,
fitHeight: HERO_FIT_HEIGHT
});
if (!pose) return undefined;
const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images');
await fs.mkdir(outDir, { recursive: true });
// Per-theme hash + render. Theme is part of the hash so light and dark
// produce distinct filenames; both variants regenerate whenever the
// route, photo set, or renderer version changes.
async function renderVariant(theme: 'light' | 'dark'): Promise<{ url: string; outName: string } | undefined> {
const fillColor = theme === 'dark' ? HERO_BADGE_FILL_DARK : HERO_BADGE_FILL_LIGHT;
const borderColor = theme === 'dark' ? HERO_BADGE_BORDER_DARK : HERO_BADGE_BORDER_LIGHT;
const iconColor = theme === 'dark' ? HERO_BADGE_ICON_DARK : HERO_BADGE_ICON_LIGHT;
const hash = crypto
.createHash('sha256')
.update(
JSON.stringify({
bbox,
w: HERO_WIDTH,
h: HERO_HEIGHT,
color: HERO_TRAIL_COLOR,
poly: polyline,
photos: photoMarkers,
fill: fillColor,
border: borderColor,
icon: iconColor,
v: HERO_RENDER_VERSION
})
)
.digest('hex')
.slice(0, 8);
const outName = `hero-${theme}.${hash}.webp`;
const outPath = path.join(outDir, outName);
if (!(await pathExists(outPath))) {
const ok = await renderStaticMap({
pose,
polyline,
color: HERO_TRAIL_COLOR,
outputPath: outPath,
width: HERO_WIDTH,
height: HERO_HEIGHT,
photoMarkers,
photoMarkerColor: fillColor,
photoMarkerBorderColor: borderColor,
photoMarkerIconColor: iconColor
});
if (!ok) return undefined;
}
return { url: `/hikes/${slug}/images/${outName}`, outName };
}
const [light, dark] = await Promise.all([renderVariant('light'), renderVariant('dark')]);
if (!light || !dark) return undefined;
return {
lightUrl: light.url,
lightOutName: light.outName,
darkUrl: dark.url,
darkOutName: dark.outName,
zoom: pose.zoom,
center: [pose.centerLat, pose.centerLng]
};
}
// ---------------------------------------------------------------------------
// Image EXIF -> ImagePoint
// ---------------------------------------------------------------------------
@@ -697,11 +847,19 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
if (r.point) imagePoints.push(r.point);
}
// Per-route icon — handled here (before cleanup) so its outName joins
// `keepFiles.images` and survives the orphan sweep, while previous-build
// `icon.<oldhash>.*` files (different hash, not in keepFiles) get removed.
const iconResult = await processIcon(slug, hikeDir);
// 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)
]);
if (iconResult) keepFiles.images.add(iconResult.outName);
if (heroResult) {
keepFiles.images.add(heroResult.lightOutName);
keepFiles.images.add(heroResult.darkOutName);
}
// Cleanup pass: drop any encoded files in either segment dir that don't
// belong to a current image. Catches both stale hashes (deleted source
@@ -764,6 +922,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
const tags = Array.isArray(fm.tags) ? fm.tags : [];
const iconUrl = iconResult?.url;
const heroMapUrlLight = heroResult?.lightUrl;
const heroMapUrlDark = heroResult?.darkUrl;
const heroMapZoom = heroResult?.zoom;
const heroMapCenter = heroResult?.center;
const entry: HikeManifestEntry = {
slug,
@@ -791,6 +953,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
pointCount: track.length,
cover,
icon: iconUrl,
heroMapUrlLight,
heroMapUrlDark,
heroMapZoom,
heroMapCenter,
imagePoints
};
+308
View File
@@ -0,0 +1,308 @@
/**
* Build-time static hero-map renderer for individual hikes.
*
* Fetches the Swisstopo raster tiles covering each hike's bbox, composites
* them into one PNG via sharp, draws the trail polyline + start/end markers
* on top, and emits a single WebP. The result is served as `<img>` in the
* detail page's hero so the user sees an exact replica of the live map
* during the few hundred milliseconds it takes Leaflet to dynamic-import,
* fetch tiles, and render — eliminating the perceived load delay.
*
* Tiles are content-cached on disk; rendered heroes are name-cached by
* content hash so a re-build with unchanged GPX is a no-op.
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
const TILE_SIZE = 256;
const TILE_CACHE_DIR = path.resolve(process.cwd(), 'scripts', '.cache', 'swisstopo-tiles');
// Swisstopo serves the WMTS tiles from wmts10wmts100. Spread across a
// couple of sub-domains so we don't hammer a single origin during initial
// build (browsers see different hosts; the disk cache makes follow-up
// builds a non-event regardless).
const SUBDOMAINS = ['wmts10', 'wmts20'] as const;
const USER_AGENT = 'bocken-homepage build-hikes';
function tileUrl(sub: string, layer: string, z: number, x: number, y: number): string {
return `https://${sub}.geo.admin.ch/1.0.0/${layer}/default/current/3857/${z}/${x}/${y}.jpeg`;
}
/** Web Mercator: lng/lat → absolute pixel coordinate at a given zoom. */
function lngLatToPx(lng: number, lat: number, zoom: number): { x: number; y: number } {
const n = 2 ** zoom;
const x = ((lng + 180) / 360) * n * TILE_SIZE;
const latRad = (lat * Math.PI) / 180;
const y =
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n * TILE_SIZE;
return { x, y };
}
async function pathExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function fetchTile(
layer: string,
z: number,
x: number,
y: number
): Promise<Buffer | null> {
const key = `${layer.replace(/[^a-z0-9]/gi, '_')}_${z}_${x}_${y}.jpeg`;
const cachePath = path.join(TILE_CACHE_DIR, key);
try {
return await fs.readFile(cachePath);
} catch { /* miss */ }
const sub = SUBDOMAINS[(x + y) % SUBDOMAINS.length];
try {
const res = await fetch(tileUrl(sub, layer, z, x, y), {
headers: { 'User-Agent': USER_AGENT }
});
if (!res.ok) return null;
const buf = Buffer.from(await res.arrayBuffer());
await fs.mkdir(TILE_CACHE_DIR, { recursive: true });
await fs.writeFile(cachePath, buf);
return buf;
} catch {
return null;
}
}
function escapeSvgNumber(n: number): string {
// Keep SVG path compact but precise enough for 1600 px rendering.
return n.toFixed(1);
}
export interface RenderStaticMapPhotoMarker {
lat: number;
lng: number;
}
export interface StaticMapPose {
zoom: number;
centerLat: number;
centerLng: number;
/** Origin in zoom-pixel space — top-left of the output canvas. The
* renderer needs it; the caller doesn't, but exposing it keeps the
* `computePose` ↔ `renderStaticMap` interface stateless. */
originX: number;
originY: number;
}
export interface ComputeStaticMapPoseOpts {
bbox: [number, number, number, number];
/** Canvas dimensions for centering / tile fetching. */
width?: number;
height?: number;
paddingPx?: number;
/** Reference dimensions used purely for zoom selection. Defaults to
* `width × height` — but pass the expected *display* size (not the
* rendered canvas size) when you want zoom to match Leaflet's
* `fitBounds` at the user's viewport. The renderer still draws the
* full `width × height` canvas around the chosen zoom, so wider
* viewports get more context without the bbox being cropped on
* smaller ones. */
fitWidth?: number;
fitHeight?: number;
}
/** Pure-math pass: pick the zoom + centre + canvas origin that the static
* renderer would use for these inputs. Identical for light- and dark-
* themed renders, so callers can compute it once and re-use. */
export function computeStaticMapPose(opts: ComputeStaticMapPoseOpts): StaticMapPose | null {
const width = opts.width ?? 1600;
const height = opts.height ?? 1000;
const paddingPx = opts.paddingPx ?? 24;
const fitWidth = opts.fitWidth ?? width;
const fitHeight = opts.fitHeight ?? height;
const [minLat, minLng, maxLat, maxLng] = opts.bbox;
if (
!Number.isFinite(minLat) || !Number.isFinite(minLng) ||
!Number.isFinite(maxLat) || !Number.isFinite(maxLng)
) {
return null;
}
const innerW = Math.max(1, fitWidth - 2 * paddingPx);
const innerH = Math.max(1, fitHeight - 2 * paddingPx);
// Pick the highest integer zoom where the bbox fits inside the
// reference inner rectangle. This mirrors Leaflet's `fitBounds`
// integer-zoom search, so a viewport matching `fitWidth × fitHeight`
// will choose the same zoom Leaflet does for the same bbox.
let zoom = 7;
for (let z = 18; z >= 7; z--) {
const tl = lngLatToPx(minLng, maxLat, z);
const br = lngLatToPx(maxLng, minLat, z);
if (br.x - tl.x <= innerW && br.y - tl.y <= innerH) {
zoom = z;
break;
}
}
const centerLat = (minLat + maxLat) / 2;
const centerLng = (minLng + maxLng) / 2;
const c = lngLatToPx(centerLng, centerLat, zoom);
const originX = Math.round(c.x - width / 2);
const originY = Math.round(c.y - height / 2);
return { zoom, centerLat, centerLng, originX, originY };
}
export interface RenderStaticMapOpts {
/** Pre-computed pose (zoom + centre + origin). Get this via
* `computeStaticMapPose(...)`. Shared by light- and dark-themed
* renders so both variants align perfectly. */
pose: StaticMapPose;
/** Track polyline as `[lat, lng]` tuples (any length). */
polyline: Array<[number, number]>;
color: string;
outputPath: string;
width?: number;
height?: number;
/** Swisstopo WMTS layer ID. Defaults to the schematic Pixelkarte (the
* same base layer Leaflet starts with on the detail page). */
layer?: string;
/** Optional image-point markers to burn into the SVG overlay alongside
* the start/end dots. Pass only the points safe to render in a public-
* facing image — private photos should be filtered out by the caller. */
photoMarkers?: RenderStaticMapPhotoMarker[];
/** Fill colour for the photo marker dots. Should match the live
* HikePhoto marker styling (`--color-primary`). */
photoMarkerColor?: string;
/** Border colour for the photo marker dots — matches the live
* `.hike-photo-marker .badge` `border-color: var(--color-surface)` so
* the static blends in with the active theme's surface colour. */
photoMarkerBorderColor?: string;
/** Stroke colour of the Lucide `camera` icon inside the badge. Matches
* the live badge's `color: var(--color-text-on-primary)` — white on
* the light theme's mid-blue primary, dark on the dark theme's light-
* blue primary. */
photoMarkerIconColor?: string;
}
/** Render and write a single static hero map at the given pose. Returns
* `false` on failure (zero tiles fetched, degenerate inputs). */
export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolean> {
const width = opts.width ?? 1600;
const height = opts.height ?? 1000;
const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe';
const { zoom, originX, originY } = opts.pose;
if (opts.polyline.length < 2) return false;
// Tiles covering [originX, originX+width) × [originY, originY+height).
const minTileX = Math.floor(originX / TILE_SIZE);
const maxTileX = Math.floor((originX + width - 1) / TILE_SIZE);
const minTileY = Math.floor(originY / TILE_SIZE);
const maxTileY = Math.floor((originY + height - 1) / TILE_SIZE);
// Parallel tile fetches — disk cache makes follow-up builds essentially
// free, but the first build pulls ~620 tiles per hike.
const tileJobs: Array<{ tx: number; ty: number; left: number; top: number }> = [];
for (let ty = minTileY; ty <= maxTileY; ty++) {
for (let tx = minTileX; tx <= maxTileX; tx++) {
tileJobs.push({
tx,
ty,
left: tx * TILE_SIZE - originX,
top: ty * TILE_SIZE - originY
});
}
}
const tileBufs = await Promise.all(
tileJobs.map(async (job) => ({
job,
buf: await fetchTile(layer, zoom, job.tx, job.ty)
}))
);
const composites: Array<{ input: Buffer; left: number; top: number }> = [];
let fetched = 0;
for (const { job, buf } of tileBufs) {
if (!buf) continue;
fetched++;
composites.push({ input: buf, left: job.left, top: job.top });
}
// Abandon when fewer than half the tiles arrived — the result would
// be too patchy to ship and we'd rather show no static map than a
// confusing one.
if (fetched < tileJobs.length / 2) return false;
// Step 1: build the bare map tile composite. Tile composite is identical
// regardless of UI theme — we deliberately don't invert the Pixelkarte
// for dark mode (its colour palette doesn't survive a naive invert).
// Only the SVG overlay below changes per theme.
const mapBuf = await sharp({
create: { width, height, channels: 3, background: { r: 235, g: 235, b: 235 } }
})
.composite(composites)
.png()
.toBuffer();
// Step 2: SVG overlay — polyline + photo markers + start/end dots.
const pathParts: string[] = [];
for (let i = 0; i < opts.polyline.length; i++) {
const [lat, lng] = opts.polyline[i];
const p = lngLatToPx(lng, lat, zoom);
const px = p.x - originX;
const py = p.y - originY;
pathParts.push((i === 0 ? 'M' : 'L') + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
}
const start = opts.polyline[0];
const end = opts.polyline[opts.polyline.length - 1];
const startP = lngLatToPx(start[1], start[0], zoom);
const endP = lngLatToPx(end[1], end[0], zoom);
const sx = escapeSvgNumber(startP.x - originX);
const sy = escapeSvgNumber(startP.y - originY);
const ex = escapeSvgNumber(endP.x - originX);
const ey = escapeSvgNumber(endP.y - originY);
const photoMarkerColor = opts.photoMarkerColor ?? '#5e81ac';
const photoMarkerBorderColor = opts.photoMarkerBorderColor ?? '#eceff4';
const photoMarkerIconColor = opts.photoMarkerIconColor ?? '#fff';
// Match HikeMap's `.hike-photo-marker .badge` — 28 px Nord-blue circle
// with a 2 px theme-surface border, holding a 14 px theme-on-primary
// Lucide `camera` icon. The camera icon paths are the literal Lucide
// source (lucide-camera).
const photoMarkers = (opts.photoMarkers ?? [])
.map((m) => {
const p = lngLatToPx(m.lng, m.lat, zoom);
const cx = escapeSvgNumber(p.x - originX);
const cy = escapeSvgNumber(p.y - originY);
return (
`<g transform="translate(${cx} ${cy})">` +
`<circle r="14" fill="${photoMarkerColor}" stroke="${photoMarkerBorderColor}" stroke-width="2"/>` +
`<g transform="translate(-7 -7) scale(0.5833)" stroke="${photoMarkerIconColor}" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">` +
`<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/>` +
`<circle cx="12" cy="13" r="3"/>` +
`</g>` +
`</g>`
);
})
.join('');
const overlay = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
`<path d="${pathParts.join(' ')}" fill="none" stroke="${opts.color}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.95"/>` +
photoMarkers +
`<circle cx="${sx}" cy="${sy}" r="9" fill="#a3be8c" stroke="#fff" stroke-width="3"/>` +
`<circle cx="${ex}" cy="${ey}" r="9" fill="#bf616a" stroke="#fff" stroke-width="3"/>` +
`</svg>`
);
await sharp(mapBuf)
.composite([{ input: overlay, left: 0, top: 0 }])
.webp({ quality: 78 })
.toFile(opts.outputPath);
return true;
}