Files
homepage/src/routes/hikes/[slug]/+page.svelte
T
Alexander 8a67f5fba8 feat(hikes): medium hero variant + Switzerland-framed overview, drop static→live wobble
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.
2026-05-26 11:51:48 +02:00

1032 lines
33 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 561899, 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, T4T6 = 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 561899 /
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 &copy;
<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
* 561899 / 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.
* T4T6: 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>