feat(hikes): pre-rendered static hero map with smooth Leaflet handover

Each hike now ships two SSR-friendly hero images (light + dark theme),
composited at build time from Swisstopo tiles plus an SVG overlay of the
trail polyline, start/end markers, and per-photo camera badges. The
detail page renders the right variant immediately at first paint, then
hands over to live Leaflet without visible jumps.

Renderer (scripts/staticHikeMap.ts):
- Parallel tile fetcher with on-disk cache (scripts/.cache/swisstopo-
  tiles/) for re-build idempotency.
- `computeStaticMapPose` picks the zoom + centre Leaflet's fitBounds
  would land on at a reference 1920x640 viewport, so the static frames
  the full route on every typical desktop hero.
- Canvas rendered at 3840x2400 — large enough to fully cover ultrawide
  / 4K displays at native pixel size, so `object-fit: none` keeps the
  trail pixel-aligned with Leaflet's tile pane.
- SVG overlay: trail in Nord red, start dot Nord green, end dot Nord
  red, Lucide `camera` icon inside each photo badge. Photo badge
  fill / border / icon-stroke colours are passed per theme so light and
  dark variants match the live `.hike-photo-marker .badge` styling
  exactly (Nord10/Nord8 fill, Nord6/Nord1 border, white/Nord0 icon
  stroke). Map tiles themselves are identical across themes — no naive
  invert (it mangles the Pixelkarte palette).
- Public photo markers only — private positions are filtered out so
  they don't leak in the SSR image.

Build wiring (scripts/build-hikes.ts):
- `processHero` renders both variants in parallel, hashes inputs per
  theme, skips on cache hit. Output filenames carry the content hash so
  changes invalidate cleanly via the existing orphan sweep.
- `HikeManifestEntry` gains `heroMapUrlLight`, `heroMapUrlDark`,
  `heroMapZoom`, `heroMapCenter`.

Detail page (src/routes/hikes/[slug]/+page.svelte):
- Reserves the hero box height up front (kills CLS).
- Renders both `<img>` tags; CSS picks the right one via `data-theme`
  with `prefers-color-scheme` as the fallback.
- `object-fit: none; object-position: center` so the image displays at
  native pixel size, perfectly aligned with Leaflet's tile rendering.
- `isolation: isolate` on the hero gives Leaflet's z-index:200+ panes
  a stacking context so they can't bleed over the sticky nav.

HikeMap (src/lib/components/hikes/HikeMap.svelte):
- New `initialCenter` / `initialZoom` props — when set, the map opens
  with `setView` at the static hero's pose instead of `fitBounds`.
- New `onReady` callback — fires after the post-fly-to-bounds tile
  batch finishes loading (or a 350 ms safety timeout), letting the
  detail page fade the static out onto fully-painted tiles instead of
  onto a brief grey gap.
- Sequence: render static -> Leaflet `setView` to match -> first tile
  load -> `flyToBounds(track)` to the natural fit -> wait for new
  tiles -> fade static out.
This commit is contained in:
2026-05-18 23:38:24 +02:00
parent f3d16d5187
commit fd2d8a58d9
6 changed files with 675 additions and 13 deletions
+104 -5
View File
@@ -26,6 +26,10 @@
let track = $state<HikeTrackPoint[] | null>(null);
let trackError = $state<string | null>(null);
// Toggled true once Leaflet's first tile batch paints. Drives the
// fade-out of the SSR-rendered static hero so the static→interactive
// handover is a soft cross-fade rather than a swap.
let heroMapReady = $state(false);
$effect(() => {
let aborted = false;
@@ -227,11 +231,48 @@
HikeMap further down sticks in the scroll-area; both share state via
the focusedImageStore so they animate together. -->
<section class="hero-map" style="view-transition-name: hike-{hike.slug}">
{#if hike.heroMapUrlLight}
<!-- Build-time static composite of Swisstopo tiles + the trail
polyline + public photo markers. Two variants ship (light
and dark); CSS picks the right one based on `data-theme`
with `prefers-color-scheme` as the fallback. Both are
rendered at the same zoom + centre as the live Leaflet
map, and displayed at native pixel size (object-fit: none)
so they overlay the live tiles exactly. The image fades
out once Leaflet's first tile batch loads. -->
<img
class="hero-static hero-static-light"
class:faded={heroMapReady}
src={hike.heroMapUrlLight}
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
/>
{/if}
{#if hike.heroMapUrlDark}
<img
class="hero-static hero-static-dark"
class:faded={heroMapReady}
src={hike.heroMapUrlDark}
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
/>
{/if}
{#if track && track.length > 0}
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate />
<HikeMap
{track}
imagePoints={visibleImagePoints}
showPrivate
initialCenter={hike.heroMapCenter}
initialZoom={hike.heroMapZoom}
onReady={() => (heroMapReady = true)}
/>
{:else if trackError}
<div class="map-fallback">Track konnte nicht geladen werden: {trackError}</div>
{:else}
{:else if !hike.heroMapUrl}
<div class="map-fallback">Track wird geladen…</div>
{/if}
<div class="hero-title">
@@ -351,19 +392,75 @@
.hero-map {
position: relative;
width: 100vw;
/* Reserve the eventual map height up-front so the page doesn't shift
* once the track JSON arrives and HikeMap mounts. Same clamp as
* `.hero-map :global(.map)` so the container and the leaflet pane
* are always congruent. */
min-height: clamp(360px, 60vh, 640px);
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 any tile area not yet painted shows the page
* background through — which already adapts to the active theme.
* Leaflet's default `#ddd` container background is overridden in
* the `.map` rule below. */
background: transparent;
}
.hero-map :global(.map) {
position: relative;
z-index: 2;
height: clamp(360px, 60vh, 640px);
border-radius: 0;
box-shadow: none;
/* Stay transparent so the SSR-rendered static map underneath shows
* through until Leaflet's tilepane paints over it. */
background: transparent;
}
/* Static hero map (pre-rendered Swisstopo composite). Displayed at
* NATIVE pixel size (`object-fit: none`) and centred — `cover` would
* scale the image and break the 1:1 pixel match with Leaflet's tile
* rendering, which is what caused the visible shift during cross-
* fade. Wider viewports just show a slightly-cropped band of the
* full image; the central region (where the trail lives) 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;
}
.hero-static.faded {
opacity: 0;
/* Once faded the live map is fully in charge; ensure the static
* image doesn't intercept hovers/clicks meant for the leaflet
* panes underneath. */
pointer-events: none;
}
/* Theme-aware switch between the two pre-rendered variants. Default
* is light; `prefers-color-scheme: dark` flips it; an explicit
* `data-theme` attribute on `<html>` always wins (higher specificity). */
.hero-static-dark { display: none; }
@media (prefers-color-scheme: dark) {
.hero-static-light { display: none; }
.hero-static-dark { display: block; }
}
:global(:root[data-theme='light']) .hero-static-light { display: block; }
:global(:root[data-theme='light']) .hero-static-dark { display: none; }
:global(:root[data-theme='dark']) .hero-static-light { display: none; }
:global(:root[data-theme='dark']) .hero-static-dark { display: block; }
/* Push Leaflet's top-left controls (zoom +/-) below the sticky nav so
* they aren't covered on narrow viewports where the nav spans the
* full width. The bottom-right controls (layer toggle, photo toggle,
@@ -626,10 +723,12 @@
}
.map-fallback {
padding: 4rem 1rem;
display: grid;
place-items: center;
height: clamp(360px, 60vh, 640px);
padding: 1rem;
text-align: center;
color: var(--color-text-tertiary);
background: var(--color-surface);
border-radius: var(--radius-card);
background: var(--color-bg-elevated);
}
</style>