Files
homepage/scripts/staticHikeMap.ts
T
Alexander fd2d8a58d9 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.
2026-05-18 23:38:24 +02:00

309 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}