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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.91.0", "version": "1.92.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -25,6 +25,10 @@
let canvas = $state<HTMLCanvasElement | undefined>(undefined); let canvas = $state<HTMLCanvasElement | undefined>(undefined);
let chart: ChartType | null = null; let chart: ChartType | null = null;
let ChartCtor: typeof import('chart.js').Chart | null = null; let ChartCtor: typeof import('chart.js').Chart | null = null;
// Goes true once Chart.js has painted at least one frame. Drives the
// cross-fade from the SSR-rendered static SVG to the interactive canvas.
// Stays sticky-true on theme re-creation so the SVG doesn't flash back.
let chartReady = $state(false);
// Cumulative distance (km) per track point — used as x axis. // Cumulative distance (km) per track point — used as x axis.
const cumKm = $derived.by(() => { const cumKm = $derived.by(() => {
@@ -46,6 +50,115 @@
return out; return out;
}); });
// SSR-rendered fallback: a static SVG profile of the whole track so no-JS
// (and pre-hydration) users see the elevation graph immediately. Path
// coordinates live in an 800×200 viewBox; `preserveAspectRatio="none"`
// stretches the path to fill the box on any aspect ratio. Strokes use
// `vector-effect: non-scaling-stroke` so the line weight stays at a
// constant pixel weight regardless of the stretch. Once Chart.js mounts
// and paints, the SVG fades out and the interactive canvas takes over.
const FALLBACK_VB_W = 800;
const FALLBACK_VB_H = 200;
const elevFallback = $derived.by(() => {
if (track.length < 2) return { fill: '', line: '' };
// Per-track sample cap so a ~5 000-point GPX doesn't produce a 60 KB
// SVG path in the HTML. ~600 samples is enough for a smooth profile
// at typical display widths and keeps the inline SVG around ~6 KB.
const target = 600;
const step = Math.max(1, Math.floor(track.length / target));
let altLo = Infinity;
let altHi = -Infinity;
for (let i = 0; i < track.length; i++) {
const a = track[i][2];
if (typeof a === 'number') {
if (a < altLo) altLo = a;
if (a > altHi) altHi = a;
}
}
if (!Number.isFinite(altLo)) return { fill: '', line: '' };
const maxKm = cumKm[cumKm.length - 1];
if (!maxKm) return { fill: '', line: '' };
// No vertical pad: the path needs to touch the top/bottom of the
// plot exactly, otherwise the HTML axis labels (min/max altitude)
// drawn next to the SVG won't line up with the actual peak/trough.
const yMin = altLo;
const ySpread = altHi - altLo || 1;
let line = '';
let firstX: number | null = null;
let lastX: number | null = null;
const append = (i: number) => {
const a = track[i][2];
if (typeof a !== 'number') return;
const x = (cumKm[i] / maxKm) * FALLBACK_VB_W;
const y = (1 - (a - yMin) / ySpread) * FALLBACK_VB_H;
if (firstX === null) {
firstX = x;
line = `M${x.toFixed(1)} ${y.toFixed(1)}`;
} else {
line += `L${x.toFixed(1)} ${y.toFixed(1)}`;
}
lastX = x;
};
for (let i = 0; i < track.length; i += step) append(i);
// Always include the last sample so the trace runs to maxKm.
if ((track.length - 1) % step !== 0) append(track.length - 1);
if (firstX === null || lastX === null) return { fill: '', line: '' };
const fill = `${line}L${lastX.toFixed(1)} ${FALLBACK_VB_H}L${firstX.toFixed(1)} ${FALLBACK_VB_H}Z`;
return { fill, line };
});
// Axis ticks for the SSR fallback: five values per axis (start, three
// intermediates, end) so each axis reads as a properly-scaled chart,
// not just labelled at the bookends. y-ticks are emitted top-to-bottom
// so the first label = max altitude, matching the SVG's y=0-at-top
// coordinate system. The three intermediate y-tick fractions (0.75,
// 0.5, 0.25) double as the soft helpline positions inside the plot,
// expressed as `viewBox` y-offsets below.
const elevFallbackKm = $derived(cumKm[cumKm.length - 1] ?? 0);
const elevFallbackYTicks = $derived.by(() => {
let lo = Infinity;
let hi = -Infinity;
for (let i = 0; i < track.length; i++) {
const a = track[i][2];
if (typeof a === 'number') {
if (a < lo) lo = a;
if (a > hi) hi = a;
}
}
if (!Number.isFinite(lo)) return null;
const min = Math.round(lo);
const max = Math.round(hi);
const span = max - min;
return [
max,
Math.round(min + span * 0.75),
Math.round(min + span * 0.5),
Math.round(min + span * 0.25),
min
];
});
const elevFallbackXTicks = $derived.by(() => {
const max = elevFallbackKm;
if (!max) return [];
return [0, max * 0.25, max * 0.5, max * 0.75, max];
});
// Horizontal helpline positions inside the SVG (viewBox y-coords).
// Only the three intermediates — the top and bottom of the plot are
// already framed by the filled area's edge, so adding gridlines there
// would just be visual noise.
const ELEV_FALLBACK_GRID_Y = [
FALLBACK_VB_H * 0.25,
FALLBACK_VB_H * 0.5,
FALLBACK_VB_H * 0.75
];
function isDark(): boolean { function isDark(): boolean {
const t = document.documentElement.getAttribute('data-theme'); const t = document.documentElement.getAttribute('data-theme');
if (t === 'dark') return true; if (t === 'dark') return true;
@@ -103,6 +216,11 @@
} }
}; };
// Flip the SSR-fallback flag synchronously with chart creation so the
// next paint already shows the canvas underneath the fading SVG.
// Set inside `createChart` (not only in `onMount`) so theme rebuilds
// don't briefly flash the SVG back; the flag is one-way (never reset).
chartReady = true;
chart = new ChartCtor(canvas, { chart = new ChartCtor(canvas, {
type: 'line', type: 'line',
data: { data: {
@@ -275,6 +393,57 @@
</script> </script>
<div class="elevation"> <div class="elevation">
<!-- Static SVG profile rendered server-side so no-JS readers (and JS
users pre-hydration) see the elevation graph without waiting on
Chart.js. The grid lays out the axis gutters (y-title + y-ticks
on the left, x-ticks + x-title under) so the SVG plot occupies
the same content region Chart.js will use for its chart area.
Once the canvas chart paints, this layer fades out and
`pointer-events: none` cedes hover to the interactive chart. -->
<div class="elev-fallback" class:hidden={chartReady} aria-hidden="true">
<div class="y-title">Höhe (m)</div>
<ol class="y-ticks">
{#if elevFallbackYTicks}
{#each elevFallbackYTicks as v (v)}
<li>{v}</li>
{/each}
{/if}
</ol>
<svg
class="elev-fallback-svg"
viewBox="0 0 {FALLBACK_VB_W} {FALLBACK_VB_H}"
preserveAspectRatio="none"
>
<!-- Soft helplines, one per intermediate y-tick. `non-scaling-
stroke` keeps them at 1 px even when the SVG is stretched
horizontally by `preserveAspectRatio="none"`. -->
<g class="elev-fallback-grid">
{#each ELEV_FALLBACK_GRID_Y as gy (gy)}
<line
x1="0"
y1={gy}
x2={FALLBACK_VB_W}
y2={gy}
vector-effect="non-scaling-stroke"
/>
{/each}
</g>
{#if elevFallback.fill}
<path d={elevFallback.fill} class="elev-fallback-fill" />
<path
d={elevFallback.line}
class="elev-fallback-line"
vector-effect="non-scaling-stroke"
/>
{/if}
</svg>
<ol class="x-ticks">
{#each elevFallbackXTicks as v (v)}
<li>{v.toFixed(1)}</li>
{/each}
</ol>
<div class="x-title">Distanz (km)</div>
</div>
<canvas bind:this={canvas} onmouseleave={onCanvasMouseLeave}></canvas> <canvas bind:this={canvas} onmouseleave={onCanvasMouseLeave}></canvas>
</div> </div>
@@ -293,5 +462,126 @@
canvas { canvas {
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
position: relative;
z-index: 2;
}
/* SSR fallback grid: y-title (rotated) + y-ticks form the left gutter,
* x-ticks + x-title form the bottom gutter, the SVG plot fills the
* remaining cell. Sized with `calc(100% - 2*padding)` rather than the
* top/right/bottom/left-inset shortcut, because `<svg>` is a replaced
* element with an intrinsic aspect ratio from `viewBox` that some
* browsers let win over a `bottom` constraint — the full-width 220 px
* chart was spilling past its rounded box at the bottom.
*
* The canvas sits on top (z-index 2) so once Chart.js paints, its
* scene fully covers this fallback; we still fade the fallback out so
* any anti-aliased edge gaps don't leak through. */
.elev-fallback {
position: absolute;
top: 0.75rem;
left: 1rem;
width: calc(100% - 2rem);
height: calc(100% - 1.5rem);
z-index: 1;
opacity: 1;
transition: opacity 250ms ease;
pointer-events: none;
display: grid;
grid-template-columns: 0.85rem 2rem 1fr;
grid-template-rows: 1fr 0.9rem 0.9rem;
font-size: 0.7rem;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.elev-fallback.hidden {
opacity: 0;
}
.y-title {
grid-row: 1;
grid-column: 1;
writing-mode: vertical-rl;
transform: rotate(180deg);
text-align: center;
align-self: center;
justify-self: center;
color: var(--color-text-secondary);
letter-spacing: 0.01em;
}
/* Y-ticks reset list defaults so they read as plain labels. `space-
* between` aligns the first/last items with the plot's top/bottom
* edges — same Y-range the SVG path uses (no altitude padding), so
* the topmost label sits on the highest peak and the bottom one at
* the lowest trough. */
.y-ticks {
grid-row: 1;
grid-column: 2;
margin: 0;
padding: 0 0.3rem 0 0;
list-style: none;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
line-height: 1;
}
.y-ticks li::after {
content: ' m';
opacity: 0.55;
}
.elev-fallback-svg {
grid-row: 1;
grid-column: 3;
width: 100%;
height: 100%;
}
/* X-ticks: 0 / mid / max, evenly distributed along the bottom of the
* plot so they line up with the SVG's left edge, midpoint, and right
* edge respectively. */
.x-ticks {
grid-row: 2;
grid-column: 3;
margin: 0;
padding: 0.15rem 0 0;
list-style: none;
display: flex;
justify-content: space-between;
align-items: flex-start;
line-height: 1;
}
.x-title {
grid-row: 3;
grid-column: 3;
text-align: center;
color: var(--color-text-secondary);
letter-spacing: 0.01em;
line-height: 1;
padding-top: 0.05rem;
}
.elev-fallback-grid line {
stroke: currentColor;
stroke-width: 1;
opacity: 0.15;
}
.elev-fallback-fill {
fill: var(--color-primary);
fill-opacity: 0.18;
}
.elev-fallback-line {
fill: none;
stroke: var(--color-primary);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
} }
</style> </style>
+127 -1
View File
@@ -39,6 +39,11 @@
// fade-out of the SSR-rendered static hero so the static→interactive // fade-out of the SSR-rendered static hero so the static→interactive
// handover is a soft cross-fade rather than a swap. // handover is a soft cross-fade rather than a swap.
let heroMapReady = $state(false); 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) // Three-band viewport switch (narrow ≤560, medium 561899, wide ≥900)
// — picks which pre-rendered pose we hand to Leaflet's first `setView` // — picks which pre-rendered pose we hand to Leaflet's first `setView`
@@ -420,6 +425,11 @@
</section> </section>
{/if} {/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"> <section class="metrics" aria-label="Tourendaten">
{#if hike.icon} {#if hike.icon}
<img class="route-icon" src={hike.icon} alt="" aria-hidden="true" /> <img class="route-icon" src={hike.icon} alt="" aria-hidden="true" />
@@ -496,7 +506,49 @@
<section class="scroll-area"> <section class="scroll-area">
<aside class="trail-col"> <aside class="trail-col">
{#if track && track.length > 0} {#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} /> <ElevationProfile {track} viewRange={stageViewRange} />
{/if} {/if}
</aside> </aside>
@@ -536,6 +588,7 @@
<a href="https://www.esri.com/" target="_blank" rel="noopener noreferrer">Esri</a> <a href="https://www.esri.com/" target="_blank" rel="noopener noreferrer">Esri</a>
</span> </span>
</footer> </footer>
</div>
</article> </article>
<style> <style>
@@ -1012,6 +1065,10 @@
.trail-col :global(.map) { .trail-col :global(.map) {
height: 400px; height: 400px;
border-radius: var(--radius-card); 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) { .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 { .map-fallback {
display: grid; display: grid;
place-items: center; place-items: center;