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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.91.0",
|
||||
"version": "1.92.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
let canvas = $state<HTMLCanvasElement | undefined>(undefined);
|
||||
let chart: ChartType | 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.
|
||||
const cumKm = $derived.by(() => {
|
||||
@@ -46,6 +50,115 @@
|
||||
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 {
|
||||
const t = document.documentElement.getAttribute('data-theme');
|
||||
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, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -275,6 +393,57 @@
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -293,5 +462,126 @@
|
||||
canvas {
|
||||
width: 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>
|
||||
|
||||
@@ -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 561–899, 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;
|
||||
|
||||
Reference in New Issue
Block a user