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
@@ -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, {