/**
* 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 `
` 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 wmts10–wmts100. 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. */
export 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 {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
/** `null` = network failure (we'll count it against the abort threshold).
* `'blank'` = HTTP 4xx, i.e. the tile is intentionally not served — for
* the Swisstopo Pixelkarte that means we're outside Switzerland's bbox.
* The overview hero canvas extends into DE/IT/FR, so we treat blanks as
* "OK, just nothing there" rather than failures. */
type TileResult = Buffer | 'blank' | null;
async function fetchTile(
layer: string,
z: number,
x: number,
y: number
): Promise {
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) {
// 4xx means "we don't serve this tile" (out-of-bounds for the
// Swiss data set). Anything else (5xx) is a real failure.
if (res.status >= 400 && res.status < 500) return 'blank';
if (process.env.STATIC_MAP_DEBUG) {
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} HTTP ${res.status}`);
}
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 (err) {
if (process.env.STATIC_MAP_DEBUG) {
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} error:`, err);
}
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;
/** Upper bound on the zoom search — mirrors Leaflet's `fitBounds({ maxZoom })`.
* Use this when the live map clamps its zoom so the static hero doesn't
* land at a more detailed level than Leaflet will ever show. */
maxZoom?: 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 maxZoom = opts.maxZoom ?? 18;
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 = maxZoom; 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;
}
/** Fetch every Swisstopo tile covering the canvas at the given pose, then
* composite them into a single PNG buffer. Returns `null` when fewer than
* half the tiles arrive (a patchy hero is worse than no hero). Shared by
* `renderStaticMap` (per-hike hero) and `renderOverviewMap` (the /hikes
* landing-page hero) so both pull the same tile cache and use the same
* fallback colour. */
async function composeBaseMap(
pose: StaticMapPose,
width: number,
height: number,
layer: string
): Promise {
const { zoom, originX, originY } = pose;
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 ~6–20 tiles per per-hike hero and
// considerably more for the overview hero.
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 networkFailures = 0;
for (const { job, buf } of tileBufs) {
if (buf === null) {
networkFailures++;
continue;
}
if (buf === 'blank') continue; // out-of-bounds, draw the fallback grey
composites.push({ input: buf, left: job.left, top: job.top });
}
// Network-failure threshold (not "fewer than half present"): blank
// out-of-bounds tiles are an expected outcome for the overview hero
// that extends past Switzerland's edges, so they don't count against
// the abort threshold.
if (networkFailures > tileJobs.length / 2) return null;
// 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 above changes per theme.
return sharp({
create: { width, height, channels: 3, background: { r: 235, g: 235, b: 235 } }
})
.composite(composites)
.png()
.toBuffer();
}
/** 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 {
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;
const mapBuf = await composeBaseMap(opts.pose, width, height, layer);
if (!mapBuf) return false;
// 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 (
`` +
`` +
`` +
`` +
`` +
`` +
``
);
})
.join('');
const overlay = Buffer.from(
``
);
await sharp(mapBuf)
.composite([{ input: overlay, left: 0, top: 0 }])
.webp({ quality: 78 })
.toFile(opts.outputPath);
return true;
}
// ---------------------------------------------------------------------------
// Overview hero (one image for the whole /hikes index page).
// Same tile composite as `renderStaticMap`, but the overlay draws many
// polylines (one per hike, coloured by SAC tier) and no per-route start /
// end / photo markers — the map is a finder, not a detail view.
// ---------------------------------------------------------------------------
export interface RenderOverviewPolyline {
points: Array<[number, number]>;
color: string;
/** Indices where a new disconnected sub-path begins (multi-day stage gaps
* >1 km), so the line isn't drawn across an overnight transfer. */
breaks?: number[];
}
export interface RenderOverviewMapOpts {
pose: StaticMapPose;
polylines: RenderOverviewPolyline[];
outputPath: string;
width?: number;
height?: number;
layer?: string;
}
export async function renderOverviewMap(opts: RenderOverviewMapOpts): Promise {
const width = opts.width ?? 1600;
const height = opts.height ?? 1000;
const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe';
const { zoom, originX, originY } = opts.pose;
const drawable = opts.polylines.filter((p) => p.points.length >= 2);
if (drawable.length === 0) return false;
const mapBuf = await composeBaseMap(opts.pose, width, height, layer);
if (!mapBuf) return false;
// One per hike polyline. The overview map is rendered fairly
// zoomed-out, so even ≤150-point preview polylines stay compact.
const paths = drawable
.map((line) => {
const breakSet = new Set(line.breaks ?? []);
const parts: string[] = [];
for (let i = 0; i < line.points.length; i++) {
const [lat, lng] = line.points[i];
const p = lngLatToPx(lng, lat, zoom);
const px = p.x - originX;
const py = p.y - originY;
// Start a fresh sub-path at index 0 and at every stage break.
const cmd = i === 0 || breakSet.has(i) ? 'M' : 'L';
parts.push(cmd + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
}
return (
``
);
})
.join('');
const overlay = Buffer.from(
``
);
await sharp(mapBuf)
.composite([{ input: overlay, left: 0, top: 0 }])
.webp({ quality: 78 })
.toFile(opts.outputPath);
return true;
}