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",
|
"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>
|
||||||
|
|||||||
@@ -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 561–899, wide ≥900)
|
// Three-band viewport switch (narrow ≤560, medium 561–899, 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user