8a67f5fba8
Three related improvements to the pre-rendered hero map system: * New medium viewport variant (561–899 CSS px) for the per-hike detail hero and the /hikes overview. Tablet/split-pane viewports were getting the wide pose (chosen for ~1920 CSS px), which landed too zoomed in. Each variant is rendered at a pose matching its container, so the static→Leaflet handover aligns at every band. Manifest fields are optional — pages fall back to the wide variant on tablets until build-hikes regenerates the images. * Overview frames on Switzerland (fixed center [46.82, 8.23]) with explicit per-variant zooms (wide=8, medium=8, narrow=7) rather than auto-fitting the union of hike bboxes. The previous behavior zoomed in on whichever corner the catalogue clustered in; this reads as "hikes across CH". Bumps OVERVIEW_RENDER_VERSION so cached overview images get invalidated on the next build. * Removed the post-tile-load flyToBounds in both HikeMap.svelte and HikesOverviewMap.svelte. The map already opens at the static pose via setView; the second auto-fit was adding a visible wobble on routes whose bbox sits at an integer-zoom boundary (e.g. the Einsiedeln–Unteriberg detail), where the build-time fit and Leaflet's runtime fit disagree by one zoom step at the user's actual container size.
1032 lines
33 KiB
Svelte
1032 lines
33 KiB
Svelte
<script lang="ts">
|
||
import HikeMap from '$lib/components/hikes/HikeMap.svelte';
|
||
import HikePhotoStrip from '$lib/components/hikes/HikePhotoStrip.svelte';
|
||
import HikeStageNav from '$lib/components/hikes/HikeStageNav.svelte';
|
||
import ElevationProfile from '$lib/components/hikes/ElevationProfile.svelte';
|
||
import { stage, clearActiveStage } from '$lib/components/hikes/stageStore.svelte';
|
||
import { isSwissRegion } from '$lib/hikes/hikeArea';
|
||
import Seo from '$lib/components/Seo.svelte';
|
||
import { setHikeContext } from '$lib/components/hikes/hikeContext.svelte';
|
||
import { listScrollAnchors } from '$lib/components/hikes/scrollAnchors';
|
||
import { setHover, clearHover } from '$lib/components/hikes/hoverStore.svelte';
|
||
import { focused, setFocused } from '$lib/components/hikes/focusedImageStore.svelte';
|
||
import Route from '@lucide/svelte/icons/route';
|
||
import Clock from '@lucide/svelte/icons/clock';
|
||
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||
import ArrowUpToLine from '@lucide/svelte/icons/arrow-up-to-line';
|
||
import ArrowDownToLine from '@lucide/svelte/icons/arrow-down-to-line';
|
||
import CalendarRange from '@lucide/svelte/icons/calendar-range';
|
||
import Download from '@lucide/svelte/icons/download';
|
||
import { buildGpx, type GpxWritePoint } from '$lib/gpx';
|
||
import { resolveCanton } from '$lib/data/cantons';
|
||
import { sacTrailColor } from '$lib/data/sacColors';
|
||
import type { HikeTrackPoint } from '$types/hikes';
|
||
import type { PageProps } from './$types';
|
||
|
||
const { data }: PageProps = $props();
|
||
const { hike } = data;
|
||
const MdxComponent = $derived(data.MdxComponent as unknown as typeof import('svelte').SvelteComponent);
|
||
const showPrivate = $derived(!!data.session?.user);
|
||
|
||
// Track is now loaded synchronously in +page.ts so the photo strip,
|
||
// elevation chart, and hero polyline are in the DOM on first paint —
|
||
// fixes both the brief layout shift when the strip used to pop in
|
||
// after an async fetch, and the /hikes → /hikes/[slug] view-transition
|
||
// slide-in (snapshot is captured before client effects run).
|
||
const track = $derived(data.track);
|
||
// 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);
|
||
|
||
// Three-band viewport switch (narrow ≤560, medium 561–899, wide ≥900)
|
||
// — picks which pre-rendered pose we hand to Leaflet's first `setView`
|
||
// so it lands aligned with the static `<img>` the CSS is showing.
|
||
// Starts `false`/`false` for SSR; the $effect snaps to real values on
|
||
// mount and keeps both flags in sync across rotate/resize. `narrow`
|
||
// wins over `medium` when both would match. See the matching overview
|
||
// pattern in `/hikes/+page.svelte`.
|
||
let narrowViewport = $state(false);
|
||
let mediumViewport = $state(false);
|
||
$effect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
const mqNarrow = window.matchMedia('(max-width: 560px)');
|
||
const mqMedium = window.matchMedia('(min-width: 561px) and (max-width: 899px)');
|
||
narrowViewport = mqNarrow.matches;
|
||
mediumViewport = mqMedium.matches;
|
||
const onNarrow = (e: MediaQueryListEvent) => { narrowViewport = e.matches; };
|
||
const onMedium = (e: MediaQueryListEvent) => { mediumViewport = e.matches; };
|
||
mqNarrow.addEventListener('change', onNarrow);
|
||
mqMedium.addEventListener('change', onMedium);
|
||
return () => {
|
||
mqNarrow.removeEventListener('change', onNarrow);
|
||
mqMedium.removeEventListener('change', onMedium);
|
||
};
|
||
});
|
||
|
||
const canton = $derived(resolveCanton(hike.canton));
|
||
const trackColor = $derived(sacTrailColor(hike.difficulty));
|
||
// swisstopo covers CH + LI; abroad the schematic caps lower (OpenTopoMap z17)
|
||
// and the Dufour layer is unavailable.
|
||
const inSwissRegion = $derived(isSwissRegion(hike.canton, hike.country));
|
||
|
||
// Publish date formatted in long German for the meta footer
|
||
// (matches the hike's `date: YYYY-MM-DD` frontmatter format).
|
||
const publishedLabel = $derived.by(() => {
|
||
const t = Date.parse(hike.date);
|
||
if (!Number.isFinite(t)) return null;
|
||
return new Date(t).toLocaleDateString('de-CH', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric'
|
||
});
|
||
});
|
||
|
||
const heroPose = $derived.by(() => {
|
||
if (
|
||
narrowViewport &&
|
||
hike.heroMapCenterNarrow &&
|
||
typeof hike.heroMapZoomNarrow === 'number'
|
||
) {
|
||
return { center: hike.heroMapCenterNarrow, zoom: hike.heroMapZoomNarrow };
|
||
}
|
||
if (
|
||
mediumViewport &&
|
||
hike.heroMapCenterMedium &&
|
||
typeof hike.heroMapZoomMedium === 'number'
|
||
) {
|
||
return { center: hike.heroMapCenterMedium, zoom: hike.heroMapZoomMedium };
|
||
}
|
||
if (hike.heroMapCenter && typeof hike.heroMapZoom === 'number') {
|
||
return { center: hike.heroMapCenter, zoom: hike.heroMapZoom };
|
||
}
|
||
return null;
|
||
});
|
||
|
||
// Active-stage scoping (multi-day hikes). When a stage is selected, the
|
||
// metrics row + elevation view switch to that stage; "Alle Etappen" (null)
|
||
// shows the whole route. Single-stage hikes never show the nav.
|
||
const stages = $derived(hike.stages ?? null);
|
||
const hasStages = $derived(!!stages && stages.length > 1);
|
||
const activeStage = $derived(hasStages && stage.active !== null ? stages![stage.active] : null);
|
||
/** Metric source: the active stage, or the whole hike on "Alle Etappen". */
|
||
const m = $derived(activeStage ?? hike);
|
||
const stageViewRange = $derived(
|
||
activeStage ? { startIdx: activeStage.startIdx, endIdx: activeStage.endIdx } : null
|
||
);
|
||
|
||
// Reset the shared selection when leaving the page.
|
||
$effect(() => () => clearActiveStage());
|
||
|
||
const durationLabel = $derived(
|
||
m.durationMin !== null && m.durationMin > 0
|
||
? `${Math.floor(m.durationMin / 60)}h ${m.durationMin % 60}m`
|
||
: '—'
|
||
);
|
||
|
||
// Map SAC tier to the painted-rectangle trail-marker colour scheme used
|
||
// in Switzerland: T1 = yellow Wanderweg, T2/T3 = white-red-white
|
||
// Bergwanderweg, T4–T6 = white-blue-white Alpinwanderweg.
|
||
const sacBand = $derived.by<'yellow' | 'red' | 'blue'>(() => {
|
||
if (hike.difficulty === 'T1') return 'yellow';
|
||
if (hike.difficulty === 'T2' || hike.difficulty === 'T3') return 'red';
|
||
return 'blue';
|
||
});
|
||
|
||
const MONTHS_DE_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||
const seasonLabel = $derived.by(() => {
|
||
const a = hike.seasonStart;
|
||
const b = hike.seasonEnd;
|
||
if (a == null || b == null) return null;
|
||
if (a < 1 || a > 12 || b < 1 || b > 12) return null;
|
||
return `${MONTHS_DE_SHORT[a - 1]}–${MONTHS_DE_SHORT[b - 1]}`;
|
||
});
|
||
|
||
// Filter visibility once at the page level so the map and the photo strip
|
||
// operate on the same index space — focused indexes are positions in this
|
||
// shared array.
|
||
const visibleImagePoints = $derived(
|
||
showPrivate
|
||
? hike.imagePoints
|
||
: hike.imagePoints.filter((ip) => ip.visibility !== 'private')
|
||
);
|
||
|
||
// Expose both the full chronological list and the visibility-filtered
|
||
// list to `<HikeImage>` instances embedded in the MDX body. The track
|
||
// is exposed too so each HikeImage can resolve its timestamp to a
|
||
// track index for the scroll-progress pin.
|
||
setHikeContext(() => ({
|
||
images: hike.imagePoints,
|
||
visibleImages: visibleImagePoints,
|
||
track,
|
||
imagesByName: hike.imagesByName ?? {},
|
||
showPrivate
|
||
}));
|
||
|
||
// Continuous trail-position tracking. As the reader scrolls through the
|
||
// content column, we sample every registered `<HikeImage>` anchor's
|
||
// viewport position and linearly interpolate between adjacent images'
|
||
// track indices using the viewport's vertical midpoint as the cursor.
|
||
// The result is pushed into the hover store, so the sticky map's pin
|
||
// glides along the trail just like it does for chart hovers.
|
||
$effect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
const mq = window.matchMedia('(min-width: 1024px)');
|
||
if (!mq.matches) return;
|
||
|
||
let frame: number | null = null;
|
||
let lastHoverIdx = -1;
|
||
let lastFocusIdx: number | null = null;
|
||
|
||
function sample(): void {
|
||
frame = null;
|
||
const anchors = listScrollAnchors();
|
||
if (anchors.length === 0) return;
|
||
// Sort by current viewport-top — that's the natural reading order
|
||
// even if a couple of images were rendered out of chronological
|
||
// sequence in the prose.
|
||
const sorted = anchors
|
||
.map((a) => ({
|
||
top: a.element.getBoundingClientRect().top,
|
||
trackIdx: a.trackIdx,
|
||
visibleIdx: a.visibleIdx
|
||
}))
|
||
.sort((a, b) => a.top - b.top);
|
||
|
||
const anchorY = window.innerHeight / 2;
|
||
let trackIdx: number;
|
||
let visibleIdx: number;
|
||
|
||
if (anchorY <= sorted[0].top) {
|
||
trackIdx = sorted[0].trackIdx;
|
||
visibleIdx = sorted[0].visibleIdx;
|
||
} else if (anchorY >= sorted[sorted.length - 1].top) {
|
||
const last = sorted[sorted.length - 1];
|
||
trackIdx = last.trackIdx;
|
||
visibleIdx = last.visibleIdx;
|
||
} else {
|
||
// Find the bracketing pair and interpolate.
|
||
let lo = sorted[0];
|
||
let hi = sorted[sorted.length - 1];
|
||
for (let i = 0; i < sorted.length - 1; i++) {
|
||
if (anchorY >= sorted[i].top && anchorY < sorted[i + 1].top) {
|
||
lo = sorted[i];
|
||
hi = sorted[i + 1];
|
||
break;
|
||
}
|
||
}
|
||
const span = hi.top - lo.top || 1;
|
||
const frac = (anchorY - lo.top) / span;
|
||
trackIdx = Math.round(lo.trackIdx + frac * (hi.trackIdx - lo.trackIdx));
|
||
// "Nearest" image — whichever bracket endpoint we're closer to.
|
||
visibleIdx = frac < 0.5 ? lo.visibleIdx : hi.visibleIdx;
|
||
}
|
||
|
||
if (trackIdx !== lastHoverIdx) {
|
||
lastHoverIdx = trackIdx;
|
||
setHover(trackIdx, 'scroll');
|
||
}
|
||
if (visibleIdx !== lastFocusIdx && focused.source !== 'strip' && focused.source !== 'map') {
|
||
lastFocusIdx = visibleIdx;
|
||
setFocused(visibleIdx, 'inline');
|
||
}
|
||
}
|
||
|
||
function onScroll(): void {
|
||
if (frame !== null) return;
|
||
frame = requestAnimationFrame(sample);
|
||
}
|
||
|
||
window.addEventListener('scroll', onScroll, { passive: true });
|
||
window.addEventListener('resize', onScroll, { passive: true });
|
||
// One initial sample so the pin sits at the right place on page load.
|
||
onScroll();
|
||
|
||
return () => {
|
||
window.removeEventListener('scroll', onScroll);
|
||
window.removeEventListener('resize', onScroll);
|
||
if (frame !== null) cancelAnimationFrame(frame);
|
||
// Clear the scroll-driven hover so the pin disappears if the user
|
||
// navigates away from the page.
|
||
clearHover();
|
||
};
|
||
});
|
||
|
||
// Client-side GPX export of just the track (no image waypoints). Built
|
||
// from the already-loaded JSON track so we don't hit the network again.
|
||
function downloadGpx(): void {
|
||
if (!track || track.length === 0) return;
|
||
const points: GpxWritePoint[] = track.map(([lng, lat, ele, t]) => ({
|
||
lat,
|
||
lng,
|
||
altitude: typeof ele === 'number' ? ele : undefined,
|
||
timestamp: typeof t === 'number' ? t : null
|
||
}));
|
||
const gpx = buildGpx({ name: hike.title, trackPoints: points });
|
||
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${hike.slug}.gpx`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
</script>
|
||
|
||
<Seo
|
||
title={`${hike.title} · Wanderungen`}
|
||
description={hike.summary}
|
||
ogType="article"
|
||
ogImage={hike.cover.src || undefined}
|
||
ogImageAlt={hike.cover.alt || undefined}
|
||
lang="de"
|
||
/>
|
||
|
||
<svelte:head>
|
||
<link rel="preconnect" href="https://maps.bocken.org" crossorigin="anonymous" />
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||
</svelte:head>
|
||
|
||
<article class="hike-detail">
|
||
<!-- The map IS the hero: the trail is the most informative thing about a
|
||
hike, so we lead with it. Title overlays at the bottom-left. A second
|
||
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}; view-transition-class: hike-fly-in">
|
||
{#if hike.heroMapUrlLight}
|
||
<!-- Build-time static composite of Swisstopo tiles + the trail
|
||
polyline + public photo markers. Six variants ship — theme
|
||
(light/dark) × viewport (wide ≥900 / medium 561–899 /
|
||
narrow ≤560 CSS px). Theme is picked by `data-theme` /
|
||
`prefers-color-scheme`; viewport by media queries. Each
|
||
variant is rendered at the same pose Leaflet's `fitBounds`
|
||
picks for its target container size, so the static→live
|
||
cross-fade aligns pixel-perfectly. The image fades out once
|
||
Leaflet's first tile batch loads. -->
|
||
<img
|
||
class="hero-static hero-static-light hero-static-wide"
|
||
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 hero-static-wide"
|
||
class:faded={heroMapReady}
|
||
src={hike.heroMapUrlDark}
|
||
alt=""
|
||
aria-hidden="true"
|
||
loading="eager"
|
||
decoding="async"
|
||
/>
|
||
{/if}
|
||
{#if hike.heroMapUrlLightMedium}
|
||
<img
|
||
class="hero-static hero-static-light hero-static-medium"
|
||
class:faded={heroMapReady}
|
||
src={hike.heroMapUrlLightMedium}
|
||
alt=""
|
||
aria-hidden="true"
|
||
loading="eager"
|
||
decoding="async"
|
||
/>
|
||
{/if}
|
||
{#if hike.heroMapUrlDarkMedium}
|
||
<img
|
||
class="hero-static hero-static-dark hero-static-medium"
|
||
class:faded={heroMapReady}
|
||
src={hike.heroMapUrlDarkMedium}
|
||
alt=""
|
||
aria-hidden="true"
|
||
loading="eager"
|
||
decoding="async"
|
||
/>
|
||
{/if}
|
||
{#if hike.heroMapUrlLightNarrow}
|
||
<img
|
||
class="hero-static hero-static-light hero-static-narrow"
|
||
class:faded={heroMapReady}
|
||
src={hike.heroMapUrlLightNarrow}
|
||
alt=""
|
||
aria-hidden="true"
|
||
loading="eager"
|
||
decoding="async"
|
||
/>
|
||
{/if}
|
||
{#if hike.heroMapUrlDarkNarrow}
|
||
<img
|
||
class="hero-static hero-static-dark hero-static-narrow"
|
||
class:faded={heroMapReady}
|
||
src={hike.heroMapUrlDarkNarrow}
|
||
alt=""
|
||
aria-hidden="true"
|
||
loading="eager"
|
||
decoding="async"
|
||
/>
|
||
{/if}
|
||
{#if track && track.length > 0}
|
||
<HikeMap
|
||
{track}
|
||
imagePoints={visibleImagePoints}
|
||
showPrivate
|
||
{trackColor}
|
||
{stages}
|
||
swissRegion={inSwissRegion}
|
||
initialCenter={heroPose?.center}
|
||
initialZoom={heroPose?.zoom}
|
||
onReady={() => (heroMapReady = true)}
|
||
/>
|
||
{:else if !hike.heroMapUrlLight}
|
||
<div class="map-fallback">Keine Trackdaten verfügbar.</div>
|
||
{/if}
|
||
<div class="hero-title">
|
||
<h1>{hike.title}</h1>
|
||
{#if hike.region}
|
||
<p class="region">
|
||
{#if canton}
|
||
<img
|
||
class="canton-emblem"
|
||
src={canton.emblemUrl}
|
||
alt=""
|
||
aria-hidden="true"
|
||
loading="eager"
|
||
decoding="async"
|
||
/>
|
||
{/if}
|
||
<span class="region-text">
|
||
{hike.region}{hike.canton && hike.canton !== hike.region
|
||
? `, ${hike.canton}`
|
||
: ''}
|
||
</span>
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
</section>
|
||
|
||
{#if hasStages && stages}
|
||
<HikeStageNav {stages} />
|
||
{/if}
|
||
|
||
{#if track && track.length > 0 && visibleImagePoints.length > 0}
|
||
<section class="strip-area" style="view-transition-name: hike-strip">
|
||
<HikePhotoStrip images={visibleImagePoints} {track} {stages} />
|
||
</section>
|
||
{/if}
|
||
|
||
<section class="metrics" aria-label="Tourendaten">
|
||
{#if hike.icon}
|
||
<img class="route-icon" src={hike.icon} alt="" aria-hidden="true" />
|
||
{/if}
|
||
<div class="metric">
|
||
<Route size={20} strokeWidth={1.75} aria-hidden="true" />
|
||
<span class="value">{m.distanceKm.toFixed(1)}<span class="value-unit">km</span></span>
|
||
<span class="unit">Distanz</span>
|
||
</div>
|
||
<div class="metric">
|
||
<Clock size={20} strokeWidth={1.75} aria-hidden="true" />
|
||
<span class="value">{durationLabel}</span>
|
||
<span class="unit">Dauer</span>
|
||
</div>
|
||
<div class="metric">
|
||
<TrendingUp size={20} strokeWidth={1.75} aria-hidden="true" />
|
||
<span class="value">{m.elevationGainM}<span class="value-unit">m</span></span>
|
||
<span class="unit">Aufstieg</span>
|
||
</div>
|
||
<div class="metric">
|
||
<TrendingDown size={20} strokeWidth={1.75} aria-hidden="true" />
|
||
<span class="value">{m.elevationLossM}<span class="value-unit">m</span></span>
|
||
<span class="unit">Abstieg</span>
|
||
</div>
|
||
{#if m.elevationMaxM !== null}
|
||
<div class="metric">
|
||
<ArrowUpToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
||
<span class="value">{m.elevationMaxM}<span class="value-unit">m</span></span>
|
||
<span class="unit">höchster</span>
|
||
</div>
|
||
{/if}
|
||
{#if m.elevationMinM !== null}
|
||
<div class="metric">
|
||
<ArrowDownToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
||
<span class="value">{m.elevationMinM}<span class="value-unit">m</span></span>
|
||
<span class="unit">tiefster</span>
|
||
</div>
|
||
{/if}
|
||
<div class="metric">
|
||
<span class="sac-marker sac-marker-{sacBand}" aria-hidden="true"></span>
|
||
<span class="value">{hike.difficulty}</span>
|
||
<span class="unit">SAC</span>
|
||
</div>
|
||
{#if seasonLabel}
|
||
<div class="metric">
|
||
<CalendarRange size={20} strokeWidth={1.75} aria-hidden="true" />
|
||
<span class="value">{seasonLabel}</span>
|
||
<span class="unit">Saison</span>
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
|
||
{#if hike.tags.length > 0}
|
||
<!-- Tag chips sit between the metric tiles (facts) and the
|
||
elevation profile (data viz) so they read as framing context —
|
||
"what kind of hike is this" — before the data takes over.
|
||
Each chip is an anchor link to the /hikes overview with that
|
||
tag pre-selected in the filter bar. -->
|
||
<section class="tags" aria-label="Schlagwörter">
|
||
{#each hike.tags as tag (tag)}
|
||
<a class="tag-chip" href="/hikes?tag={encodeURIComponent(tag)}">
|
||
<span class="tag-hash" aria-hidden="true">#</span>{tag}
|
||
</a>
|
||
{/each}
|
||
</section>
|
||
{/if}
|
||
|
||
{#if track && track.length > 0}
|
||
<section class="elev-area">
|
||
<ElevationProfile {track} viewRange={stageViewRange} />
|
||
</section>
|
||
{/if}
|
||
|
||
<section class="scroll-area">
|
||
<aside class="trail-col">
|
||
{#if track && track.length > 0}
|
||
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate {trackColor} {stages} swissRegion={inSwissRegion} />
|
||
<ElevationProfile {track} viewRange={stageViewRange} />
|
||
{/if}
|
||
</aside>
|
||
|
||
<section class="content-col">
|
||
<MdxComponent />
|
||
</section>
|
||
</section>
|
||
|
||
<!-- Quiet meta footer: route metadata + sources + ancillary actions.
|
||
De-emphasises the GPX download (previously a centred primary
|
||
button) by grouping it with other "extras" that a small minority
|
||
of readers care about. -->
|
||
<footer class="meta-footer">
|
||
<button
|
||
type="button"
|
||
class="meta-link"
|
||
onclick={downloadGpx}
|
||
disabled={!track || track.length === 0}
|
||
title="GPX-Datei mit nur dem Track (ohne Bilder) herunterladen"
|
||
>
|
||
<Download size={13} strokeWidth={2} aria-hidden="true" />
|
||
<span>GPX-Track herunterladen</span>
|
||
</button>
|
||
<span class="meta-dot" aria-hidden="true">·</span>
|
||
<span>{hike.pointCount.toLocaleString('de-CH')} Wegpunkte</span>
|
||
{#if publishedLabel}
|
||
<span class="meta-dot" aria-hidden="true">·</span>
|
||
<span>Veröffentlicht {publishedLabel}</span>
|
||
{/if}
|
||
<span class="meta-dot" aria-hidden="true">·</span>
|
||
<span>
|
||
Kartendaten ©
|
||
<a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener noreferrer">swisstopo</a>,
|
||
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a>,
|
||
<a href="https://opentopomap.org/" target="_blank" rel="noopener noreferrer">OpenTopoMap</a>,
|
||
<a href="https://www.esri.com/" target="_blank" rel="noopener noreferrer">Esri</a>
|
||
</span>
|
||
</footer>
|
||
</article>
|
||
|
||
<style>
|
||
.hike-detail {
|
||
max-width: 1400px;
|
||
margin-inline: auto;
|
||
padding: 0 0 4rem;
|
||
}
|
||
|
||
/* Hero map is full-bleed: breaks out of the centered `.hike-detail`
|
||
* container to span the entire viewport width and extends *under* the
|
||
* sticky nav (which is glass-blurred and sits above with z-index). The
|
||
* `calc(50% - 50vw)` trick stretches a child of a centered parent
|
||
* edge-to-edge; the negative top margin pulls the map back up over
|
||
* the gap that the nav's height + top-margin would otherwise leave. */
|
||
.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;
|
||
}
|
||
|
||
/* 2×3 picker: theme (light/dark) × viewport (wide ≥900 / medium
|
||
* 561–899 / narrow ≤560). Each `<img>` carries both qualifiers (e.g.
|
||
* `.hero-static-light.hero-static-wide`); we hide everything by
|
||
* default and reveal exactly one based on the active theme and the
|
||
* viewport media queries. Each variant is rendered at a fit matching
|
||
* its band so Leaflet picks the same integer zoom on first paint. */
|
||
.hero-static { display: none; }
|
||
|
||
/* Default (light theme assumed): show the wide-light, then step down
|
||
* the cascade as viewports shrink. */
|
||
.hero-static-light.hero-static-wide { display: block; }
|
||
@media (max-width: 899px) {
|
||
.hero-static-light.hero-static-wide { display: none; }
|
||
.hero-static-light.hero-static-medium { display: block; }
|
||
}
|
||
@media (max-width: 560px) {
|
||
.hero-static-light.hero-static-medium { display: none; }
|
||
.hero-static-light.hero-static-narrow { display: block; }
|
||
}
|
||
|
||
@media (prefers-color-scheme: dark) {
|
||
.hero-static-light.hero-static-wide,
|
||
.hero-static-light.hero-static-medium,
|
||
.hero-static-light.hero-static-narrow { display: none; }
|
||
.hero-static-dark.hero-static-wide { display: block; }
|
||
@media (max-width: 899px) {
|
||
.hero-static-dark.hero-static-wide { display: none; }
|
||
.hero-static-dark.hero-static-medium { display: block; }
|
||
}
|
||
@media (max-width: 560px) {
|
||
.hero-static-dark.hero-static-medium { display: none; }
|
||
.hero-static-dark.hero-static-narrow { display: block; }
|
||
}
|
||
}
|
||
|
||
/* Explicit `data-theme` always wins. */
|
||
:global(:root[data-theme='light']) .hero-static-dark { display: none !important; }
|
||
:global(:root[data-theme='light']) .hero-static-light.hero-static-wide { display: block; }
|
||
@media (max-width: 899px) {
|
||
:global(:root[data-theme='light']) .hero-static-light.hero-static-wide { display: none; }
|
||
:global(:root[data-theme='light']) .hero-static-light.hero-static-medium { display: block; }
|
||
}
|
||
@media (max-width: 560px) {
|
||
:global(:root[data-theme='light']) .hero-static-light.hero-static-medium { display: none; }
|
||
:global(:root[data-theme='light']) .hero-static-light.hero-static-narrow { display: block; }
|
||
}
|
||
|
||
:global(:root[data-theme='dark']) .hero-static-light { display: none !important; }
|
||
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-wide { display: block; }
|
||
@media (max-width: 899px) {
|
||
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-wide { display: none; }
|
||
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-medium { display: block; }
|
||
}
|
||
@media (max-width: 560px) {
|
||
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-medium { display: none; }
|
||
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-narrow { 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,
|
||
* GPS) sit clear of the nav already. */
|
||
.hero-map :global(.leaflet-top) {
|
||
top: calc(3rem + max(12px, env(safe-area-inset-top, 0px) + 4px) + 0.5rem);
|
||
}
|
||
|
||
.hero-title {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
padding: 1.5rem 1.5rem 1.25rem;
|
||
background: linear-gradient(to top, rgb(0 0 0 / 0.6), transparent);
|
||
color: white;
|
||
pointer-events: none;
|
||
z-index: 400;
|
||
}
|
||
|
||
.hero-title h1 {
|
||
margin: 0;
|
||
font-size: clamp(1.5rem, 4vw, 2.2rem);
|
||
text-shadow: 0 2px 8px rgb(0 0 0 / 0.45);
|
||
}
|
||
|
||
.region {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin: 0.2rem 0 0;
|
||
opacity: 0.9;
|
||
text-shadow: 0 1px 4px rgb(0 0 0 / 0.45);
|
||
}
|
||
|
||
.canton-emblem {
|
||
flex: 0 0 auto;
|
||
width: 24px;
|
||
height: 30px;
|
||
object-fit: contain;
|
||
/* Drop shadow keeps the emblem readable on the gradient overlay
|
||
* (which only goes dark from ~50 % down). */
|
||
filter: drop-shadow(0 1px 3px rgb(0 0 0 / 0.5));
|
||
}
|
||
|
||
.region-text {
|
||
min-width: 0;
|
||
}
|
||
|
||
.metrics {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
/* Center, don't stretch: otherwise each tile is pulled to the tall
|
||
* route-icon's height and its value/descriptor rows spread apart. */
|
||
align-items: center;
|
||
gap: 1rem 2.25rem;
|
||
padding: 1.5rem 1rem;
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.route-icon {
|
||
width: 96px;
|
||
height: 96px;
|
||
object-fit: contain;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.metric {
|
||
display: grid;
|
||
grid-template-columns: auto auto;
|
||
grid-template-rows: auto auto;
|
||
column-gap: 0.55rem;
|
||
row-gap: 0.05rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.metric :global(svg) {
|
||
grid-row: 1 / span 2;
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
.metrics .value {
|
||
font-size: 1.35rem;
|
||
line-height: 1.1;
|
||
color: var(--color-text-primary);
|
||
font-variant-numeric: tabular-nums;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.value-unit {
|
||
font-size: 0.7em;
|
||
font-weight: 500;
|
||
color: var(--color-text-secondary);
|
||
margin-left: 0.15em;
|
||
}
|
||
|
||
.metrics .unit {
|
||
font-size: 0.78rem;
|
||
color: var(--color-text-tertiary);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
/* SAC trail-marker pictograms in landscape orientation.
|
||
* T1: yellow Wegweiser-style sign with a right-pointing arrow tip.
|
||
* T2/T3: white-red-white painted Bergwanderweg marker.
|
||
* T4–T6: white-blue-white painted Alpinwanderweg marker. */
|
||
.sac-marker {
|
||
grid-row: 1 / span 2;
|
||
width: 28px;
|
||
height: 20px;
|
||
}
|
||
|
||
.sac-marker-yellow {
|
||
width: 32px;
|
||
background: #f5a623;
|
||
/* Pentagon → flat left, arrow point on the right, like a Swiss
|
||
* hiking-trail Wegweiser. Clip-path overrides any border so the
|
||
* outline is supplied by filter: drop-shadow instead. */
|
||
clip-path: polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%);
|
||
filter: drop-shadow(0 0 0.5px rgb(0 0 0 / 0.45));
|
||
}
|
||
|
||
.sac-marker-red {
|
||
border-radius: 2px;
|
||
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.18);
|
||
background: linear-gradient(
|
||
to bottom,
|
||
#fff 0 25%,
|
||
#dc1d2a 25% 75%,
|
||
#fff 75% 100%
|
||
);
|
||
}
|
||
|
||
.sac-marker-blue {
|
||
border-radius: 2px;
|
||
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.18);
|
||
background: linear-gradient(
|
||
to bottom,
|
||
#fff 0 25%,
|
||
#2965c8 25% 75%,
|
||
#fff 75% 100%
|
||
);
|
||
}
|
||
|
||
/* Tag chips: muted pills between the metric block and the elevation
|
||
* chart. Quieter than the metric tiles (lighter bg, no border, no
|
||
* shadow) so the metric numbers stay the dominant glance-info, and
|
||
* the tags read as framing context — "what kind of hike". */
|
||
.tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
gap: 0.4rem 0.5rem;
|
||
padding: 0 1rem 0.25rem;
|
||
}
|
||
|
||
.tag-chip {
|
||
display: inline-flex;
|
||
align-items: baseline;
|
||
gap: 0.15rem;
|
||
padding: 0.25rem 0.7rem;
|
||
font-size: 0.78rem;
|
||
line-height: 1;
|
||
font-weight: 500;
|
||
color: var(--color-text-secondary);
|
||
background: var(--color-bg-tertiary);
|
||
border-radius: var(--radius-pill);
|
||
letter-spacing: 0.005em;
|
||
text-decoration: none;
|
||
transition:
|
||
color var(--transition-fast),
|
||
background-color var(--transition-fast),
|
||
scale var(--transition-fast);
|
||
}
|
||
|
||
.tag-chip:hover {
|
||
color: var(--color-primary);
|
||
background: var(--color-bg-elevated);
|
||
scale: 1.05;
|
||
}
|
||
|
||
.tag-hash {
|
||
color: var(--color-text-tertiary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tag-chip:hover .tag-hash {
|
||
color: color-mix(in oklab, var(--color-primary) 60%, currentColor);
|
||
}
|
||
|
||
.elev-area {
|
||
padding: 0 1rem;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
/* Meta footer: small, muted, centred, separated by middots. Groups
|
||
* the GPX download with other ancillary metadata (waypoint count,
|
||
* publish date, swisstopo attribution) so it reads as "extras" rather
|
||
* than a primary CTA. */
|
||
.meta-footer {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 0.35rem 0.6rem;
|
||
padding: 2rem 1.5rem 0;
|
||
margin-top: 2rem;
|
||
border-top: 1px solid var(--color-border);
|
||
font-size: 0.78rem;
|
||
color: var(--color-text-tertiary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.meta-footer a {
|
||
color: inherit;
|
||
text-decoration: underline;
|
||
text-decoration-color: color-mix(in oklab, currentColor 35%, transparent);
|
||
text-underline-offset: 0.18em;
|
||
transition: color var(--transition-fast), text-decoration-color var(--transition-fast);
|
||
}
|
||
|
||
.meta-footer a:hover {
|
||
color: var(--color-primary);
|
||
text-decoration-color: currentColor;
|
||
}
|
||
|
||
.meta-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
padding: 0;
|
||
font: inherit;
|
||
color: inherit;
|
||
background: none;
|
||
border: 0;
|
||
cursor: pointer;
|
||
text-decoration: underline;
|
||
text-decoration-color: color-mix(in oklab, currentColor 35%, transparent);
|
||
text-underline-offset: 0.18em;
|
||
transition: color var(--transition-fast), text-decoration-color var(--transition-fast);
|
||
}
|
||
|
||
.meta-link:hover:not(:disabled) {
|
||
color: var(--color-primary);
|
||
text-decoration-color: currentColor;
|
||
}
|
||
|
||
.meta-link:disabled {
|
||
opacity: 0.55;
|
||
cursor: not-allowed;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.meta-link :global(svg) {
|
||
flex: 0 0 auto;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.meta-dot {
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.strip-area {
|
||
padding-inline: 1rem;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.scroll-area {
|
||
padding-inline: 1rem;
|
||
margin-top: 1.5rem;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
gap: 1.5rem;
|
||
}
|
||
|
||
/* Mobile: the hero map at the top is the only map; the secondary sticky
|
||
* map (and the elevation profile that lived next to it) are redundant
|
||
* since there's no scrollytelling without the two-column layout. */
|
||
.trail-col {
|
||
display: none;
|
||
}
|
||
|
||
.trail-col,
|
||
.content-col {
|
||
min-width: 0;
|
||
}
|
||
|
||
.content-col {
|
||
font-size: 1rem;
|
||
line-height: 1.65;
|
||
}
|
||
|
||
.content-col :global(p) {
|
||
margin: 0 0 1.2rem;
|
||
}
|
||
|
||
.content-col :global(h2) {
|
||
margin: 2rem 0 0.75rem;
|
||
font-size: 1.5rem;
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.content-col :global(blockquote) {
|
||
margin: 1.5rem 0;
|
||
padding: 0.6rem 1rem;
|
||
border-left: 3px solid var(--color-primary);
|
||
background: var(--color-surface);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-text-secondary);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Desktop scrollytelling: a sticky trail column on the left holding a
|
||
* smaller copy of the map + elevation profile, with prose + inline
|
||
* images flowing on the right. Below 1024 px the columns stack and the
|
||
* trail loses its stickiness.
|
||
*
|
||
* For `position: sticky` to actually engage, the grid item's own height
|
||
* must be smaller than the row's resolved height — `align-self: start`
|
||
* stops the grid from stretching the cell to the row's full height
|
||
* (which would otherwise leave no scroll room for the sticky to move
|
||
* against). The trail-col contains only the secondary map + elevation
|
||
* here (the strip lives above, the photos inline), so it stays short. */
|
||
@media (min-width: 1024px) {
|
||
.scroll-area {
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||
gap: 2.5rem;
|
||
align-items: start;
|
||
}
|
||
|
||
.trail-col {
|
||
display: block;
|
||
position: sticky;
|
||
/* The global nav is itself sticky (3 rem tall, ~12 px top offset),
|
||
* so anchor the map below it with a small breathing gap. */
|
||
top: calc(3rem + max(12px, env(safe-area-inset-top, 0px) + 4px) + 0.75rem);
|
||
align-self: start;
|
||
}
|
||
|
||
.trail-col :global(.map) {
|
||
height: 400px;
|
||
border-radius: var(--radius-card);
|
||
}
|
||
|
||
.trail-col :global(.elevation) {
|
||
height: 180px;
|
||
}
|
||
}
|
||
|
||
.map-fallback {
|
||
display: grid;
|
||
place-items: center;
|
||
height: clamp(360px, 60vh, 640px);
|
||
padding: 1rem;
|
||
text-align: center;
|
||
color: var(--color-text-tertiary);
|
||
background: var(--color-bg-elevated);
|
||
}
|
||
</style>
|