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