feat(hikes): no-JS elevation SVG + static trail-col map

- Render the elevation profile as an inline SVG at SSR (filled area
  + 5 ticks per axis + soft horizontal helplines). Chart.js takes
  over via a sticky `chartReady` flag once it imports and paints,
  fading the SVG out.
- Pre-rendered medium hero now underlays the desktop trail-col map,
  cover-cropped and `transform: scale(2.25)`d so the bbox fills the
  slot. Fades on first leaflet tile-paint, same handover as the
  hero map further up.
- Wrap everything below the photo strip in `.below-strip` so the
  view-transition into the detail page can slide the metrics,
  tags, charts, scroll-area and footer as a single block.
This commit is contained in:
2026-05-26 22:48:41 +02:00
parent 8a67f5fba8
commit 0f6c50f854
3 changed files with 418 additions and 2 deletions
+127 -1
View File
@@ -39,6 +39,11 @@
// 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);
// Same trick for the secondary sticky map in the desktop scroll-area:
// the pre-rendered hero image shows behind Leaflet, giving no-JS readers
// a real map (instead of an empty rounded box) and bridging the gap
// until tiles paint for JS users.
let trailMapReady = $state(false);
// Three-band viewport switch (narrow ≤560, medium 561899, wide ≥900)
// — picks which pre-rendered pose we hand to Leaflet's first `setView`
@@ -420,6 +425,11 @@
</section>
{/if}
<!-- Everything below the photo strip is wrapped so view-transitions
can slide the whole block (metrics, tags, elevation chart, scroll
area, footer) up from the bottom on enter and down on exit. The
hero map and strip animate separately above this. -->
<div class="below-strip" style="view-transition-name: hike-below-strip">
<section class="metrics" aria-label="Tourendaten">
{#if hike.icon}
<img class="route-icon" src={hike.icon} alt="" aria-hidden="true" />
@@ -496,7 +506,49 @@
<section class="scroll-area">
<aside class="trail-col">
{#if track && track.length > 0}
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate {trackColor} {stages} swissRegion={inSwissRegion} />
<!-- Wrapper turns the secondary map into its own stacking
context so the pre-rendered hero `<img>` underlays the
live Leaflet pane. Use the medium (tablet-sized) hero
variant: the wide one is framed for a 1920×640
desktop band and leaves the track tiny in the centre,
while the narrow one is sized for phones and its
aspect (1:1) doesn't match the desktop-only
trail-col's wider 1.66:1 slot. Medium (2400×1500,
1.6:1) lines up closest. Falls back to wide if a
hike somehow lacks the medium render. -->
<div class="trail-map-wrap">
{#if hike.heroMapUrlLightMedium ?? hike.heroMapUrlLight}
<img
class="trail-static trail-static-light"
class:faded={trailMapReady}
src={hike.heroMapUrlLightMedium ?? hike.heroMapUrlLight}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
/>
{/if}
{#if hike.heroMapUrlDarkMedium ?? hike.heroMapUrlDark}
<img
class="trail-static trail-static-dark"
class:faded={trailMapReady}
src={hike.heroMapUrlDarkMedium ?? hike.heroMapUrlDark}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
/>
{/if}
<HikeMap
{track}
imagePoints={visibleImagePoints}
showPrivate
{trackColor}
{stages}
swissRegion={inSwissRegion}
onReady={() => (trailMapReady = true)}
/>
</div>
<ElevationProfile {track} viewRange={stageViewRange} />
{/if}
</aside>
@@ -536,6 +588,7 @@
<a href="https://www.esri.com/" target="_blank" rel="noopener noreferrer">Esri</a>
</span>
</footer>
</div>
</article>
<style>
@@ -1012,6 +1065,10 @@
.trail-col :global(.map) {
height: 400px;
border-radius: var(--radius-card);
/* Transparent so the underlay `<img>` shows through until the
* live tile-pane has finished painting. Same trick as the hero
* map further up the page. */
background: transparent;
}
.trail-col :global(.elevation) {
@@ -1019,6 +1076,75 @@
}
}
.trail-map-wrap {
position: relative;
width: 100%;
/* Clip the scaled underlay (see `.trail-static` below) and let the
* wrapper own the rounded corners that the live leaflet pane
* otherwise contributes — keeps the shape consistent across the
* static → live handover. */
overflow: hidden;
border-radius: var(--radius-card);
}
/* Secondary-map underlay: pre-rendered medium hero (2400×1500 canvas
* framed for a 1000×500 tablet fit). Cover-cropped to the trail-col
* slot and magnified ~2.25× so the bbox region fills most of the
* visible area while still keeping a little surrounding context
* around the trail. Leaflet paints over this before anyone clocks
* the framing shift, and no-JS readers simply see the static
* composite framed on the track. */
.trail-static {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
transform: scale(2.25);
transform-origin: center;
opacity: 1;
transition: opacity 450ms ease;
pointer-events: none;
z-index: 1;
display: none;
}
.trail-static.faded {
opacity: 0;
}
/* Default (light theme assumed): show the light variant. */
.trail-static-light {
display: block;
}
@media (prefers-color-scheme: dark) {
.trail-static-light {
display: none;
}
.trail-static-dark {
display: block;
}
}
/* Explicit `data-theme` always wins. */
:global(:root[data-theme='light']) .trail-static-dark {
display: none !important;
}
:global(:root[data-theme='light']) .trail-static-light {
display: block;
}
:global(:root[data-theme='dark']) .trail-static-light {
display: none !important;
}
:global(:root[data-theme='dark']) .trail-static-dark {
display: block;
}
.map-fallback {
display: grid;
place-items: center;