feat(hikes): forgiving map selection, photo lightbox, detail polish
Map interaction: - Overview map: widen the canvas renderer hit-test (tolerance) so a route can be hovered/clicked from a comfortable margin instead of demanding a pixel-perfect click on the thin line. - Detail map: drive the elevation cursor from a whole-map mousemove that snaps to the nearest track point within ~70 px (track cached in layer-point space, refreshed on zoom/move), instead of requiring the pointer to ride exactly on the trail. The hover pin now renders for map-sourced hovers too, and is recoloured to nord red as a distinct "you are here" marker. Trail polyline made non-interactive. Detail page: - Move the photo strip above the stats row and trim it (3:2 cards). - Add a fullscreen lightbox: an expand button on each card opens the full-res image with prev/next, arrow keys, Esc, backdrop-close and a body-scroll lock; opening/stepping syncs the map + strip. The card's existing click (map-position sync) is preserved. - Cap inline prose images at 680 px (centered) so they don't blow up to full width in the single-column layout on wider screens; the desktop two-column layout is unaffected.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.78.0",
|
"version": "1.79.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -79,7 +79,13 @@
|
|||||||
<style>
|
<style>
|
||||||
.hike-image {
|
.hike-image {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 2rem 0;
|
/* Cap the width so that in the single-column (mobile/tablet) layout the
|
||||||
|
* photo doesn't blow up to the full content width on wider screens.
|
||||||
|
* On the desktop two-column layout the prose column is already narrower
|
||||||
|
* than this, so it stays full-bleed-in-column there. Centered via
|
||||||
|
* auto inline margins. */
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 2rem auto;
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #14181f;
|
background: #14181f;
|
||||||
|
|||||||
@@ -223,10 +223,14 @@
|
|||||||
(getComputedStyle(document.documentElement).getPropertyValue('--red').trim() ||
|
(getComputedStyle(document.documentElement).getPropertyValue('--red').trim() ||
|
||||||
'#bf616a');
|
'#bf616a');
|
||||||
|
|
||||||
|
// Non-interactive: hover is driven by the whole-map `mousemove`
|
||||||
|
// handler below (snap-to-nearest), so the line itself needn't grab
|
||||||
|
// the pointer cursor or events.
|
||||||
const polyline = L.polyline(latLngs, {
|
const polyline = L.polyline(latLngs, {
|
||||||
color: trailColor,
|
color: trailColor,
|
||||||
weight: 4,
|
weight: 4,
|
||||||
opacity: 0.95
|
opacity: 0.95,
|
||||||
|
interactive: false
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
L.circleMarker(latLngs[0], {
|
L.circleMarker(latLngs[0], {
|
||||||
@@ -373,7 +377,6 @@
|
|||||||
// before the pin actually hits the edge.
|
// before the pin actually hits the edge.
|
||||||
const stopHoverEffect = $effect.root(() => {
|
const stopHoverEffect = $effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (hover.source === 'map') return;
|
|
||||||
if (hover.index === null || hover.index < 0 || hover.index >= latLngs.length) {
|
if (hover.index === null || hover.index < 0 || hover.index >= latLngs.length) {
|
||||||
hoverMarker.remove();
|
hoverMarker.remove();
|
||||||
return;
|
return;
|
||||||
@@ -381,6 +384,9 @@
|
|||||||
const ll = latLngs[hover.index];
|
const ll = latLngs[hover.index];
|
||||||
hoverMarker.setLatLng(ll);
|
hoverMarker.setLatLng(ll);
|
||||||
hoverMarker.addTo(map);
|
hoverMarker.addTo(map);
|
||||||
|
// Only auto-pan for cursors driven from elsewhere (chart /
|
||||||
|
// scroll tracker). A map-sourced hover means the user is
|
||||||
|
// already pointing here, so panning would fight them.
|
||||||
if (hover.source === 'chart' || hover.source === 'scroll') {
|
if (hover.source === 'chart' || hover.source === 'scroll') {
|
||||||
const inner = map.getBounds().pad(-0.12);
|
const inner = map.getBounds().pad(-0.12);
|
||||||
if (!inner.contains(ll)) {
|
if (!inner.contains(ll)) {
|
||||||
@@ -435,23 +441,44 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Polyline hover → write to store.
|
// Elevation tracking: rather than requiring the pointer to be exactly
|
||||||
polyline.on('mousemove', (e: { latlng: { lat: number; lng: number } }) => {
|
// on the thin trail, snap the chart cursor to the nearest track point
|
||||||
|
// whenever the mouse is anywhere within HOVER_SNAP_PX of the route.
|
||||||
|
// The track is cached in layer-point (pixel) space so each pointer
|
||||||
|
// move is just cheap distance maths; the cache is rebuilt on zoom/
|
||||||
|
// move (layer points are pan-invariant, but rebuilding on moveend
|
||||||
|
// keeps it correct regardless of how the view changed).
|
||||||
|
const HOVER_SNAP_PX = 70;
|
||||||
|
let projected: { x: number; y: number }[] = [];
|
||||||
|
function reproject() {
|
||||||
|
projected = latLngs.map((ll) => map.latLngToLayerPoint(ll));
|
||||||
|
}
|
||||||
|
reproject();
|
||||||
|
map.on('zoomend moveend', reproject);
|
||||||
|
|
||||||
|
map.on('mousemove', (e: { layerPoint: { x: number; y: number } }) => {
|
||||||
|
if (projected.length === 0) return;
|
||||||
|
const { x, y } = e.layerPoint;
|
||||||
let bestIdx = 0;
|
let bestIdx = 0;
|
||||||
let bestSq = Infinity;
|
let bestSq = Infinity;
|
||||||
const { lat, lng } = e.latlng;
|
for (let i = 0; i < projected.length; i++) {
|
||||||
for (let i = 0; i < latLngs.length; i++) {
|
const dx = projected[i].x - x;
|
||||||
const dLat = latLngs[i][0] - lat;
|
const dy = projected[i].y - y;
|
||||||
const dLng = latLngs[i][1] - lng;
|
const sq = dx * dx + dy * dy;
|
||||||
const sq = dLat * dLat + dLng * dLng;
|
|
||||||
if (sq < bestSq) {
|
if (sq < bestSq) {
|
||||||
bestSq = sq;
|
bestSq = sq;
|
||||||
bestIdx = i;
|
bestIdx = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setHover(bestIdx, 'map');
|
if (bestSq <= HOVER_SNAP_PX * HOVER_SNAP_PX) {
|
||||||
|
setHover(bestIdx, 'map');
|
||||||
|
} else if (hover.source === 'map') {
|
||||||
|
clearHover();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
map.on('mouseout', () => {
|
||||||
|
if (hover.source === 'map') clearHover();
|
||||||
});
|
});
|
||||||
polyline.on('mouseout', () => clearHover());
|
|
||||||
|
|
||||||
// User location (opt-in).
|
// User location (opt-in).
|
||||||
let userMarker: ReturnType<typeof L.circleMarker> | null = null;
|
let userMarker: ReturnType<typeof L.circleMarker> | null = null;
|
||||||
@@ -799,7 +826,10 @@
|
|||||||
:global(.hike-hover-pin) {
|
:global(.hike-hover-pin) {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
color: var(--color-primary);
|
/* Nord red — deliberately off the primary palette so the cursor pin
|
||||||
|
* reads as a distinct "you are here" marker against the blue-ish
|
||||||
|
* trail / UI accents. `currentColor` drives the SVG fill. */
|
||||||
|
color: var(--red);
|
||||||
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.25));
|
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.25));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
import type { HikeTrackPoint, ImagePoint } from '$types/hikes';
|
import type { HikeTrackPoint, ImagePoint } from '$types/hikes';
|
||||||
import { focused, setFocused } from './focusedImageStore.svelte';
|
import { focused, setFocused } from './focusedImageStore.svelte';
|
||||||
import MapPin from '@lucide/svelte/icons/map-pin';
|
import MapPin from '@lucide/svelte/icons/map-pin';
|
||||||
import Lock from '@lucide/svelte/icons/lock';
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
|
import Expand from '@lucide/svelte/icons/expand';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
images: ImagePoint[];
|
images: ImagePoint[];
|
||||||
@@ -29,9 +32,58 @@
|
|||||||
return m === 0 ? `${h} h` : `${h} h ${m} min`;
|
return m === 0 ? `${h} h` : `${h} h ${m} min`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardEls: Array<HTMLButtonElement | null> = $state([]);
|
const cardEls: Array<HTMLElement | null> = $state([]);
|
||||||
let scrollEl = $state<HTMLDivElement | undefined>(undefined);
|
let scrollEl = $state<HTMLDivElement | undefined>(undefined);
|
||||||
|
|
||||||
|
// Fullscreen lightbox. Independent of `focused` (which drives the map),
|
||||||
|
// but opening / navigating also syncs `focused` so the map + strip follow
|
||||||
|
// whatever is being viewed full-screen.
|
||||||
|
let lightboxIndex = $state<number | null>(null);
|
||||||
|
const lightboxOpen = $derived(lightboxIndex !== null);
|
||||||
|
let closeBtn = $state<HTMLButtonElement | undefined>(undefined);
|
||||||
|
|
||||||
|
function openLightbox(i: number): void {
|
||||||
|
lightboxIndex = i;
|
||||||
|
setFocused(i, 'strip');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox(): void {
|
||||||
|
lightboxIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightboxStep(dir: -1 | 1): void {
|
||||||
|
if (lightboxIndex === null) return;
|
||||||
|
const n = lightboxIndex + dir;
|
||||||
|
if (n < 0 || n >= images.length) return;
|
||||||
|
lightboxIndex = n;
|
||||||
|
setFocused(n, 'strip');
|
||||||
|
}
|
||||||
|
|
||||||
|
// While open: Esc closes, arrows navigate, body scroll is locked, and focus
|
||||||
|
// moves into the dialog. Keyed on `lightboxOpen` (not the index) so stepping
|
||||||
|
// between images doesn't re-run the setup or steal focus back to close.
|
||||||
|
$effect(() => {
|
||||||
|
if (!lightboxOpen) return;
|
||||||
|
closeBtn?.focus();
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeLightbox();
|
||||||
|
else if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
lightboxStep(-1);
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
lightboxStep(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Recenter the active card horizontally inside the strip on focus change.
|
// Recenter the active card horizontally inside the strip on focus change.
|
||||||
// We scroll only the strip's own X axis — `scrollIntoView` would also
|
// We scroll only the strip's own X axis — `scrollIntoView` would also
|
||||||
// pull the page Y to bring the strip into the viewport, which is not
|
// pull the page Y to bring the strip into the viewport, which is not
|
||||||
@@ -122,31 +174,40 @@
|
|||||||
? formatElapsed(ip.timestamp - startTimestamp)
|
? formatElapsed(ip.timestamp - startTimestamp)
|
||||||
: null}
|
: null}
|
||||||
{@const active = focused.index === i}
|
{@const active = focused.index === i}
|
||||||
<button
|
<div class="card-wrap" class:active bind:this={cardEls[i]}>
|
||||||
type="button"
|
<button
|
||||||
class="card"
|
type="button"
|
||||||
class:active
|
class="card"
|
||||||
class:private={ip.visibility === 'private'}
|
class:private={ip.visibility === 'private'}
|
||||||
bind:this={cardEls[i]}
|
onclick={() => onCardClick(i)}
|
||||||
onclick={() => onCardClick(i)}
|
aria-label={`Foto ${i + 1} von ${images.length}${elapsed ? `, nach ${elapsed}` : ''}`}
|
||||||
aria-label={`Foto ${i + 1} von ${images.length}${elapsed ? `, nach ${elapsed}` : ''}`}
|
role="option"
|
||||||
role="option"
|
aria-selected={active}
|
||||||
aria-selected={active}
|
>
|
||||||
>
|
<img src={ip.thumbnail} alt={ip.alt} loading="lazy" decoding="async" />
|
||||||
<img src={ip.thumbnail} alt={ip.alt} loading="lazy" decoding="async" />
|
<div class="overlay">
|
||||||
<div class="overlay">
|
{#if elapsed}
|
||||||
{#if elapsed}
|
<span class="chip-elapsed">nach {elapsed}</span>
|
||||||
<span class="chip-elapsed">nach {elapsed}</span>
|
{/if}
|
||||||
|
<span class="chip-index">{i + 1}/{images.length}</span>
|
||||||
|
</div>
|
||||||
|
{#if ip.visibility === 'private'}
|
||||||
|
<span class="badge-private" aria-label="Privat">
|
||||||
|
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
privat
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="chip-index">{i + 1}/{images.length}</span>
|
</button>
|
||||||
</div>
|
<button
|
||||||
{#if ip.visibility === 'private'}
|
type="button"
|
||||||
<span class="badge-private" aria-label="Privat">
|
class="expand"
|
||||||
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
|
aria-label={`Foto ${i + 1} im Vollbild öffnen`}
|
||||||
privat
|
title="Vollbild"
|
||||||
</span>
|
onclick={() => openLightbox(i)}
|
||||||
{/if}
|
>
|
||||||
</button>
|
<Expand size={15} strokeWidth={2} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -161,6 +222,52 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if lightboxIndex !== null}
|
||||||
|
{@const ip = images[lightboxIndex]}
|
||||||
|
{@const elapsed =
|
||||||
|
ip.timestamp != null && startTimestamp != null
|
||||||
|
? formatElapsed(ip.timestamp - startTimestamp)
|
||||||
|
: null}
|
||||||
|
<div
|
||||||
|
class="lightbox"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Foto ${lightboxIndex + 1} von ${images.length}`}
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
>
|
||||||
|
<button class="lb-backdrop" aria-label="Schließen" onclick={closeLightbox}></button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="lb-btn lb-close"
|
||||||
|
aria-label="Schließen"
|
||||||
|
bind:this={closeBtn}
|
||||||
|
onclick={closeLightbox}
|
||||||
|
>
|
||||||
|
<X size={22} strokeWidth={2} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if lightboxIndex > 0}
|
||||||
|
<button class="lb-btn lb-prev" aria-label="Vorheriges Bild" onclick={() => lightboxStep(-1)}>
|
||||||
|
<ChevronLeft size={26} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if lightboxIndex < images.length - 1}
|
||||||
|
<button class="lb-btn lb-next" aria-label="Nächstes Bild" onclick={() => lightboxStep(1)}>
|
||||||
|
<ChevronRight size={26} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<figure class="lb-figure">
|
||||||
|
<img src={ip.src} alt={ip.alt} />
|
||||||
|
<figcaption class="lb-caption">
|
||||||
|
<span class="lb-count">{lightboxIndex + 1} / {images.length}</span>
|
||||||
|
{#if elapsed}<span class="lb-elapsed">nach {elapsed}</span>{/if}
|
||||||
|
{#if ip.alt}<span class="lb-alt">{ip.alt}</span>{/if}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -228,27 +335,46 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
/* The wrapper is the flex item: it carries the size, scroll-snap and the
|
||||||
|
* lift/scale transform. The card button and the expand button live inside
|
||||||
|
* it as siblings (a button can't be nested in a button). */
|
||||||
|
.card-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 232px;
|
width: 232px;
|
||||||
|
scroll-snap-align: center;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrap:hover,
|
||||||
|
.card-wrap:focus-within {
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active card stands out via a much heavier, tinted drop shadow rather
|
||||||
|
* than dimming everything else — keeps every photo legible. */
|
||||||
|
.card-wrap.active {
|
||||||
|
transform: translateY(-6px) scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
scroll-snap-align: center;
|
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
transform: translateY(0) scale(1);
|
transition: box-shadow 220ms ease;
|
||||||
transition:
|
|
||||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
||||||
box-shadow 220ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover,
|
.card-wrap:hover .card,
|
||||||
.card:focus-visible {
|
.card-wrap:focus-within .card {
|
||||||
transform: translateY(-2px) scale(1.02);
|
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,19 +383,72 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active card stands out via a much heavier, tinted drop shadow rather
|
.card-wrap.active .card {
|
||||||
* than dimming everything else — keeps every photo legible. */
|
|
||||||
.card.active {
|
|
||||||
transform: translateY(-6px) scale(1.05);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 18px 32px -8px color-mix(in oklab, var(--color-primary) 55%, transparent),
|
0 18px 32px -8px color-mix(in oklab, var(--color-primary) 55%, transparent),
|
||||||
0 6px 14px -6px rgb(0 0 0 / 0.25);
|
0 6px 14px -6px rgb(0 0 0 / 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fullscreen trigger — a circular badge in the top-right of each card.
|
||||||
|
* Hidden until the card is hovered/focused/active (always shown on touch
|
||||||
|
* devices, which have no hover). */
|
||||||
|
.expand {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
z-index: 3;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(0 0 0 / 0.5);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
transition:
|
||||||
|
opacity var(--transition-fast),
|
||||||
|
transform var(--transition-fast),
|
||||||
|
background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrap:hover .expand,
|
||||||
|
.card-wrap:focus-within .expand,
|
||||||
|
.card-wrap.active .expand {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand:hover {
|
||||||
|
background: rgb(0 0 0 / 0.72);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand:focus-visible {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.expand {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card img {
|
.card img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4 / 3;
|
/* 3:2 — a touch shorter than the old 4:3 so the strip sits compactly
|
||||||
|
* above the stats row without dominating the page. */
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: var(--color-bg-elevated);
|
background: var(--color-bg-elevated);
|
||||||
}
|
}
|
||||||
@@ -367,7 +546,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.card {
|
.card-wrap {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,11 +565,153 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card-wrap,
|
||||||
.card,
|
.card,
|
||||||
.strip-scroll,
|
.strip-scroll,
|
||||||
.chev {
|
.chev,
|
||||||
|
.expand {
|
||||||
transition: none;
|
transition: none;
|
||||||
scroll-behavior: auto;
|
scroll-behavior: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Fullscreen lightbox ─────────────────────────────────────────────── */
|
||||||
|
.lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgb(0 0 0 / 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-figure {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-figure img {
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 82vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 12px 48px rgb(0 0 0 / 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-caption {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem 0.85rem;
|
||||||
|
max-width: 92vw;
|
||||||
|
color: rgb(255 255 255 / 0.88);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-count {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-elapsed {
|
||||||
|
color: rgb(255 255 255 / 0.7);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-alt {
|
||||||
|
color: rgb(255 255 255 / 0.6);
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-btn {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 0.12);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-btn:hover {
|
||||||
|
background: rgb(255 255 255 / 0.24);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-btn:focus-visible {
|
||||||
|
outline: 2px solid #fff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-close {
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-prev {
|
||||||
|
left: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-next {
|
||||||
|
right: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-prev:hover,
|
||||||
|
.lb-next:hover {
|
||||||
|
transform: translateY(-50%) scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.lb-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-close {
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-prev {
|
||||||
|
left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-next {
|
||||||
|
right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -98,10 +98,15 @@
|
|||||||
const L = await import('leaflet');
|
const L = await import('leaflet');
|
||||||
if (cancelled || !node.isConnected) return;
|
if (cancelled || !node.isConnected) return;
|
||||||
|
|
||||||
|
// `tolerance` widens the canvas renderer's hit-test radius around
|
||||||
|
// every polyline (hit = weight/2 + tolerance), so a route can be
|
||||||
|
// hovered/clicked from a comfortable margin instead of demanding a
|
||||||
|
// pixel-perfect click on the 4 px line.
|
||||||
const map = L.map(node, {
|
const map = L.map(node, {
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
preferCanvas: true
|
preferCanvas: true,
|
||||||
|
renderer: L.canvas({ tolerance: 12 })
|
||||||
});
|
});
|
||||||
// Sensible default centre (mid-Switzerland) while the polyline
|
// Sensible default centre (mid-Switzerland) while the polyline
|
||||||
// layer is built up; `fitBounds` below overrides it once the
|
// layer is built up; `fitBounds` below overrides it once the
|
||||||
|
|||||||
@@ -372,6 +372,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if track && track.length > 0 && visibleImagePoints.length > 0}
|
||||||
|
<section class="strip-area">
|
||||||
|
<HikePhotoStrip images={visibleImagePoints} {track} />
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<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" />
|
||||||
@@ -445,12 +451,6 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if track && track.length > 0 && visibleImagePoints.length > 0}
|
|
||||||
<section class="strip-area">
|
|
||||||
<HikePhotoStrip images={visibleImagePoints} {track} />
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<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}
|
||||||
|
|||||||
Reference in New Issue
Block a user