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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user