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:
2026-05-22 12:36:06 +02:00
parent 896e42f5d9
commit bb1d494c48
6 changed files with 424 additions and 62 deletions
+1 -1
View File
@@ -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": {
+7 -1
View File
@@ -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;
+41 -11
View File
@@ -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;
} }
} }
if (bestSq <= HOVER_SNAP_PX * HOVER_SNAP_PX) {
setHover(bestIdx, 'map'); 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;
} }
+340 -19
View File
@@ -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,12 +174,11 @@
? formatElapsed(ip.timestamp - startTimestamp) ? formatElapsed(ip.timestamp - startTimestamp)
: null} : null}
{@const active = focused.index === i} {@const active = focused.index === i}
<div class="card-wrap" class:active bind:this={cardEls[i]}>
<button <button
type="button" type="button"
class="card" class="card"
class:active
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"
@@ -147,6 +198,16 @@
</span> </span>
{/if} {/if}
</button> </button>
<button
type="button"
class="expand"
aria-label={`Foto ${i + 1} im Vollbild öffnen`}
title="Vollbild"
onclick={() => openLightbox(i)}
>
<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
+6 -6
View File
@@ -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}