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
+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. */