feat(hikes): pre-rendered overview hero map with same handover pattern

Mirrors the per-hike detail-page hero on the /hikes index. Build emits one
WebP at the union bbox of every visible hike with each preview polyline
drawn in its SAC-tier colour; page renders it under the live Leaflet map
and fades it out once the first tile batch loads.

Tile fetcher now distinguishes HTTP 4xx ("intentionally blank — outside
Switzerland") from real network errors, so the larger overview canvas
that extends into DE/IT/FR doesn't trip the network-failure abort.
This commit is contained in:
2026-05-19 08:18:23 +02:00
parent fd2d8a58d9
commit fe08e06a02
6 changed files with 427 additions and 38 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.72.0",
"version": "1.73.0",
"private": true,
"type": "module",
"scripts": {
+137 -3
View File
@@ -27,10 +27,11 @@ import {
type GpxPoint
} from '../src/lib/server/gpx.js';
import { simplifyTrack } from '../src/lib/server/simplifyTrack.js';
import { computeStaticMapPose, renderStaticMap } from './staticHikeMap.js';
import { computeStaticMapPose, renderOverviewMap, renderStaticMap } from './staticHikeMap.js';
import type {
Difficulty,
HikeManifestEntry,
HikesOverview,
ImagePoint,
ImageVariant
} from '../src/types/hikes.js';
@@ -586,6 +587,133 @@ const HERO_BADGE_ICON_DARK = '#2e3440';
// existing files get re-rendered on the next build.
const HERO_RENDER_VERSION = 5;
// 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
// `clamp(320px, 50vh, 520px)` hero at desktop viewports.
const OVERVIEW_FIT_WIDTH = 1920;
const OVERVIEW_FIT_HEIGHT = 520;
const OVERVIEW_PADDING_PX = 32;
const OVERVIEW_MAX_ZOOM = 13;
// Bump alongside `HERO_RENDER_VERSION` (or independently) when the overview
// renderer's output changes — e.g. stroke widths, palette tweaks.
const OVERVIEW_RENDER_VERSION = 1;
async function processOverview(
hikes: HikeManifestEntry[]
): Promise<HikesOverview | undefined> {
const lines = hikes
.filter((h) => h.previewPolyline && h.previewPolyline.length >= 2)
.map((h) => ({
points: h.previewPolyline,
color: OVERVIEW_SAC_COLOR[h.difficulty] ?? '#5e81ac'
}));
if (lines.length === 0) return undefined;
// Union bbox over every hike's bbox — that's what Leaflet's
// `fitBounds(bounds)` operates on with `extend()` per polyline. Using
// each hike's bbox rather than every polyline point keeps the math
// cheap without losing the framing accuracy.
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const h of hikes) {
const [a, b, c, d] = h.bbox;
if (a < minLat) minLat = a;
if (c > maxLat) maxLat = c;
if (b < minLng) minLng = b;
if (d > maxLng) maxLng = d;
}
if (!Number.isFinite(minLat)) return undefined;
const bbox: [number, number, number, number] = [minLat, minLng, maxLat, maxLng];
const pose = computeStaticMapPose({
bbox,
width: HERO_WIDTH,
height: HERO_HEIGHT,
paddingPx: OVERVIEW_PADDING_PX,
fitWidth: OVERVIEW_FIT_WIDTH,
fitHeight: OVERVIEW_FIT_HEIGHT,
maxZoom: OVERVIEW_MAX_ZOOM
});
if (!pose) return undefined;
const hash = crypto
.createHash('sha256')
.update(
JSON.stringify({
bbox,
w: HERO_WIDTH,
h: HERO_HEIGHT,
lines,
maxZoom: OVERVIEW_MAX_ZOOM,
pad: OVERVIEW_PADDING_PX,
v: OVERVIEW_RENDER_VERSION
})
)
.digest('hex')
.slice(0, 8);
// Slug "_overview" picks up the same vite dev-server image plugin and
// nginx public-serve rules as per-hike assets, without colliding with
// any real hike slug (leading underscore is not a valid slug character).
const slug = '_overview';
const outName = `overview.${hash}.webp`;
const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images');
await fs.mkdir(outDir, { recursive: true });
const outPath = path.join(outDir, outName);
const renderT0 = Date.now();
console.log(
`[build-hikes:_overview] ${lines.length} polylines · zoom ${pose.zoom} · ` +
`${Math.round(HERO_WIDTH / 256)}×${Math.round(HERO_HEIGHT / 256)} tile grid`
);
if (!(await pathExists(outPath))) {
const ok = await renderOverviewMap({
pose,
polylines: lines,
outputPath: outPath,
width: HERO_WIDTH,
height: HERO_HEIGHT
});
if (!ok) {
console.warn(`[build-hikes:_overview] render failed — too few tiles fetched`);
return undefined;
}
console.log(`[build-hikes:_overview] rendered ${outName} in ${Date.now() - renderT0}ms`);
} else {
console.log(`[build-hikes:_overview] cached (${outName})`);
}
// Sweep orphan overview heroes from previous builds.
try {
const existing = await fs.readdir(outDir);
const orphans = existing.filter((f) => f !== outName);
if (orphans.length > 0) {
await Promise.all(orphans.map((f) => fs.unlink(path.join(outDir, f)).catch(() => {})));
console.log(`[build-hikes:_overview] removed ${orphans.length} orphaned file(s)`);
}
} catch {
// dir didn't exist before this run
}
return {
url: `/hikes/${slug}/images/${outName}`,
zoom: pose.zoom,
center: [pose.centerLat, pose.centerLng]
};
}
async function processHero(
slug: string,
track: GpxPoint[],
@@ -990,11 +1118,17 @@ async function main() {
hikes.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
// Build the overview hero from the listing-visible set (matches what
// `/hikes` shows: hidden hikes are filtered out by the page loader).
const overview = await processOverview(hikes.filter((h) => !h.hidden));
await fs.mkdir(path.dirname(MANIFEST_OUT), { recursive: true });
const banner =
'// AUTO-GENERATED by scripts/build-hikes.ts — do not edit by hand.\n' +
"import type { HikeManifestEntry } from '$types/hikes';\n\n";
const body = `export const HIKES: HikeManifestEntry[] = ${JSON.stringify(hikes, null, 2)} as const;\n`;
"import type { HikeManifestEntry, HikesOverview } from '$types/hikes';\n\n";
const body =
`export const HIKES: HikeManifestEntry[] = ${JSON.stringify(hikes, null, 2)} as const;\n\n` +
`export const HIKES_OVERVIEW: HikesOverview | null = ${JSON.stringify(overview ?? null, null, 2)};\n`;
const manifestSrc = banner + body;
await fs.writeFile(MANIFEST_OUT, manifestSrc);
+138 -28
View File
@@ -48,12 +48,19 @@ async function pathExists(p: string): Promise<boolean> {
}
}
/** `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<Buffer | null> {
): Promise<TileResult> {
const key = `${layer.replace(/[^a-z0-9]/gi, '_')}_${z}_${x}_${y}.jpeg`;
const cachePath = path.join(TILE_CACHE_DIR, key);
try {
@@ -65,12 +72,23 @@ async function fetchTile(
const res = await fetch(tileUrl(sub, layer, z, x, y), {
headers: { 'User-Agent': USER_AGENT }
});
if (!res.ok) return null;
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 {
} catch (err) {
if (process.env.STATIC_MAP_DEBUG) {
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} error:`, err);
}
return null;
}
}
@@ -111,6 +129,10 @@ export interface ComputeStaticMapPoseOpts {
* 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
@@ -122,6 +144,7 @@ export function computeStaticMapPose(opts: ComputeStaticMapPoseOpts): StaticMapP
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 (
@@ -139,7 +162,7 @@ export function computeStaticMapPose(opts: ComputeStaticMapPoseOpts): StaticMapP
// 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--) {
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) {
@@ -189,24 +212,28 @@ export interface RenderStaticMapOpts {
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;
/** 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<Buffer | null> {
const { zoom, originX, originY } = 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.
// free, but the first build pulls ~620 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++) {
@@ -226,29 +253,46 @@ export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolea
);
const composites: Array<{ input: Buffer; left: number; top: number }> = [];
let fetched = 0;
let networkFailures = 0;
for (const { job, buf } of tileBufs) {
if (!buf) continue;
fetched++;
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 });
}
// 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;
// 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;
// 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({
// 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();
}
// Step 2: SVG overlay — polyline + photo markers + start/end dots.
/** 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;
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];
@@ -306,3 +350,69 @@ export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolea
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;
}
export interface RenderOverviewMapOpts {
pose: StaticMapPose;
polylines: RenderOverviewPolyline[];
outputPath: string;
width?: number;
height?: number;
layer?: string;
}
export async function renderOverviewMap(opts: RenderOverviewMapOpts): 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;
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 <path> 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 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;
parts.push((i === 0 ? 'M' : 'L') + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
}
return (
`<path d="${parts.join(' ')}" fill="none" stroke="${line.color}" ` +
`stroke-width="4" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.9"/>`
);
})
.join('');
const overlay = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
paths +
`</svg>`
);
await sharp(mapBuf)
.composite([{ input: overlay, left: 0, top: 0 }])
.webp({ quality: 78 })
.toFile(opts.outputPath);
return true;
}
@@ -13,9 +13,19 @@
interface Props {
hikes: HikeManifestEntry[];
/** Initial map centre `[lat, lng]`. When provided alongside
* `initialZoom`, the map opens with `setView(center, zoom)` instead
* of `fitBounds(union)` — used by the index page to align Leaflet's
* first paint with the SSR-rendered static overview hero. */
initialCenter?: [number, number];
initialZoom?: number;
/** Fires once the schematic tile layer's first batch of tiles has
* finished loading — i.e. the map is visually complete. The page
* uses this to fade out the SSR-rendered static hero. */
onReady?: () => void;
}
const { hikes }: Props = $props();
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,
@@ -103,7 +113,17 @@
attributionControl: true,
zoomControl: true,
preferCanvas: true
}).setView([46.8, 8.3], 8);
});
// Sensible default centre (mid-Switzerland) while the polyline
// layer is built up; `fitBounds` below overrides it once the
// union bounds are known. If the caller passed a pre-rendered
// hero pose, use that instead so Leaflet lands aligned with the
// static image on first paint.
if (initialCenter && typeof initialZoom === 'number') {
map.setView(initialCenter, initialZoom, { animate: false });
} else {
map.setView([46.8, 8.3], 8);
}
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
schematic: L.tileLayer(SWISSTOPO_FARBE, {
@@ -128,8 +148,44 @@
tileLayers.schematic.addTo(map);
let currentBase: BaseLayer = 'schematic';
// Forward-declared so the tile-load handover handler below can
// close over it; populated once the polyline loop has built the
// union bounds.
let initialBounds: ReturnType<typeof L.latLngBounds> | null = null;
// First-paint handover: when the schematic tile layer finishes
// loading its initial batch, fire `onReady` (so the static hero
// can fade out) and — if we opened with `setView` to match a
// pre-rendered hero — animate to Leaflet's natural `fitBounds`
// of the union polyline bounds. The fade overlaps with the zoom
// animation so the user sees the map ease into its final
// framing as the static dissolves. Mirrors the same pattern in
// `HikeMap.svelte`.
tileLayers.schematic.once('load', () => {
if (!initialCenter || typeof initialZoom !== 'number' || !initialBounds) {
onReady?.();
return;
}
map.flyToBounds(initialBounds, {
padding: [32, 32],
maxZoom: 13,
duration: 0.9,
easeLinearity: 0.3
});
map.once('moveend', () => {
let fired = false;
const fire = () => {
if (fired) return;
fired = true;
onReady?.();
};
tileLayers.schematic.once('load', fire);
setTimeout(fire, 350);
});
});
// One polyline per hike, sourced from the manifest's already-
// simplified previewPolyline (≤30 points each).
// simplified previewPolyline (≤150 points each).
const layer = L.layerGroup().addTo(map);
const bounds = L.latLngBounds([]);
for (const hike of hikes) {
@@ -164,10 +220,16 @@
}
}
let initialBounds: ReturnType<typeof L.latLngBounds> | null = null;
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [32, 32], maxZoom: 13 });
initialBounds = bounds;
// When the caller handed us a pre-rendered hero pose, we
// already called `setView(initialCenter, initialZoom)` above
// and rely on the tile-load handler to fly to bounds (so the
// static→live cross-fade happens at the matching pose). With
// no pre-rendered hero, fitBounds straight away.
if (!initialCenter || typeof initialZoom !== 'number') {
map.fitBounds(initialBounds, { padding: [32, 32], maxZoom: 13 });
}
recenterMap = () => {
if (!initialBounds) return;
map.flyToBounds(initialBounds, {
+70 -1
View File
@@ -4,11 +4,17 @@
import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.svelte';
import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte';
import Seo from '$lib/components/Seo.svelte';
import { HIKES_OVERVIEW } from '$lib/data/hikes.generated';
import type { Difficulty } from '$types/hikes';
import type { PageProps } from './$types';
const { data }: PageProps = $props();
// Fades the SSR-rendered static overview hero out once Leaflet's first
// schematic-tile batch has loaded. Same handover pattern as the detail
// page's hero map.
let heroMapReady = $state(false);
// Filter ceilings start wide-open so the initial render (SSR + first
// hydration pass) shows every hike. `$effect` below clamps them down
// to the actual data maxes once `data.hikes` is fully populated —
@@ -80,7 +86,31 @@
<section class="hikes-page">
<section class="hero-map" aria-label="Übersicht">
<HikesOverviewMap hikes={visible} />
{#if HIKES_OVERVIEW}
<!-- Build-time static composite of Swisstopo tiles + every
visible hike's preview polyline, coloured by SAC tier.
Displayed at native pixel size (`object-fit: none`) so it
overlays Leaflet's live tiles exactly. The image fades out
once Leaflet's first tile batch loads. Unlike the detail
hero, the overview map looks the same in light and dark
mode (only the per-hike camera badges are theme-aware,
and the overview has none) so a single variant ships. -->
<img
class="hero-static"
class:faded={heroMapReady}
src={HIKES_OVERVIEW.url}
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
/>
{/if}
<HikesOverviewMap
hikes={visible}
initialCenter={HIKES_OVERVIEW?.center}
initialZoom={HIKES_OVERVIEW?.zoom}
onReady={() => (heroMapReady = true)}
/>
</section>
<div class="below-hero">
@@ -130,11 +160,50 @@
position: relative;
isolation: isolate;
width: 100vw;
/* Reserve the eventual map height up-front so the static image and
* Leaflet's tile pane sit on a stable surface (no scroll-shift when
* either mounts). Same clamp as `:global(.overview-map)` inside
* the HikesOverviewMap component. */
min-height: clamp(320px, 50vh, 520px);
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
margin-top: calc(-1 * (3rem + max(12px, env(safe-area-inset-top, 0px) + 4px)));
margin-bottom: 0;
overflow: hidden;
/* Transparent so the page background shows through any tile gap
* during the static→live cross-fade rather than Leaflet's grey
* default. */
background: transparent;
}
/* Pre-rendered overview hero. Native pixel size + centred so it matches
* Leaflet's tile rendering 1:1; `cover` would scale and break alignment
* during the cross-fade. Wider viewports just reveal more of the
* 3840×2400 canvas; the union bbox (where the trails live) is always
* pixel-aligned with the live map. */
.hero-static {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: none;
object-position: center;
z-index: 1;
opacity: 1;
transition: opacity 450ms ease;
pointer-events: none;
}
.hero-static.faded {
opacity: 0;
}
/* Live overview map sits above the static; transparent so the static
* shows through until Leaflet's tile pane paints over it. */
.hero-map :global(.overview-map) {
position: relative;
z-index: 2;
background: transparent;
}
/* Push Leaflet's top-left controls below the sticky nav. */
+14
View File
@@ -94,3 +94,17 @@ export type HikeManifestEntry = {
// Geo-tagged photos shown as map markers on the detail page:
imagePoints: ImagePoint[];
};
/** Pre-rendered hero map for the `/hikes` index page. One image covers
* every listed hike's `previewPolyline`, coloured by SAC tier. The page
* shows it under the sticky nav until Leaflet's first tile batch loads,
* then fades it out — same handover pattern as the per-hike detail hero. */
export type HikesOverview = {
/** Absolute URL of the pre-rendered WebP. */
url: string;
/** Integer zoom the static was rendered at (matches Leaflet's
* `fitBounds(unionBounds, { padding: 32, maxZoom: 13 })` choice). */
zoom: number;
/** Centre `[lat, lng]` the static was rendered around. */
center: [number, number];
};