feat(hikes): phone-sized static hero variant for ≤560 px viewports

The wide static hero picks its zoom for a desktop-sized container
(fitWidth 1920), so on phones the bbox lands too zoomed-in: most of
the route falls outside the visible 400 CSS px band.

Build now emits a second pose per hero — rendered with fitWidth 400 /
fitHeight 480 onto a 1200² canvas — so the auto-fit zoom matches what
Leaflet picks at the same container size. Per-hike hero gains four
variants total (theme × viewport); overview hero gains two.

The page picks which `<img>` to show via a `max-width: 560px` media
query (no JS needed for the swap), and `matchMedia` decides which
pose to hand to Leaflet's first `setView` so the static→live cross-
fade aligns regardless of viewport.

Drive-by: replace the long-stale `hike.heroMapUrl` reference in the
detail page's track-loading fallback with `hike.heroMapUrlLight`.
This commit is contained in:
2026-05-19 08:27:08 +02:00
parent fe08e06a02
commit c082da700d
5 changed files with 441 additions and 176 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.73.0", "version": "1.74.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+257 -140
View File
@@ -587,6 +587,46 @@ const HERO_BADGE_ICON_DARK = '#2e3440';
// existing files get re-rendered on the next build. // existing files get re-rendered on the next build.
const HERO_RENDER_VERSION = 5; const HERO_RENDER_VERSION = 5;
// Narrow-viewport variant for phones (≤ 560 px CSS width). Same renderer,
// but the pose is picked for a phone-sized container so the auto-fit zoom
// matches what Leaflet computes there. Canvas stays modest (1200²) since
// the image only needs to cover phone viewports — wider screens fall back
// to the wide hero. `object-fit: none` again pins the centre to the
// container midpoint, so any extra image bleed shows on the edges only.
const HERO_NARROW_WIDTH = 1200;
const HERO_NARROW_HEIGHT = 1200;
// Typical phone hero: ~400 CSS px wide (median portrait phone),
// `clamp(360, 60vh, 640)` ≈ 480 tall on a ~800 px screen. Pick a
// representative square so both detail (60vh) and overview (50vh) heroes
// stay correctly framed across the phone breakpoint range.
const HERO_NARROW_FIT_WIDTH = 400;
const HERO_NARROW_FIT_HEIGHT = 480;
type HeroVariant = 'wide' | 'narrow';
const HERO_VARIANT_SPECS: ReadonlyArray<{
name: HeroVariant;
width: number;
height: number;
fitWidth: number;
fitHeight: number;
}> = [
{
name: 'wide',
width: HERO_WIDTH,
height: HERO_HEIGHT,
fitWidth: HERO_FIT_WIDTH,
fitHeight: HERO_FIT_HEIGHT
},
{
name: 'narrow',
width: HERO_NARROW_WIDTH,
height: HERO_NARROW_HEIGHT,
fitWidth: HERO_NARROW_FIT_WIDTH,
fitHeight: HERO_NARROW_FIT_HEIGHT
}
];
// SAC-tier polyline colours for the overview hero. Must stay in sync with // SAC-tier polyline colours for the overview hero. Must stay in sync with
// the `SAC_COLOR` map in `HikesOverviewMap.svelte` so the static hero's // the `SAC_COLOR` map in `HikesOverviewMap.svelte` so the static hero's
// trails look identical to the live ones. // trails look identical to the live ones.
@@ -611,6 +651,29 @@ const OVERVIEW_MAX_ZOOM = 13;
// renderer's output changes — e.g. stroke widths, palette tweaks. // renderer's output changes — e.g. stroke widths, palette tweaks.
const OVERVIEW_RENDER_VERSION = 1; const OVERVIEW_RENDER_VERSION = 1;
type OverviewVariantSpec = {
name: HeroVariant;
width: number;
height: number;
fitWidth: number;
fitHeight: number;
};
// Overview narrow uses the same canvas dims as the per-hike narrow but
// fits the union bbox at phone size — same `maxZoom: 13` clamp as the
// live map's `fitBounds`.
const OVERVIEW_VARIANT_SPECS: ReadonlyArray<OverviewVariantSpec> = [
{ name: 'wide', width: HERO_WIDTH, height: HERO_HEIGHT, fitWidth: OVERVIEW_FIT_WIDTH, fitHeight: OVERVIEW_FIT_HEIGHT },
{ name: 'narrow', width: HERO_NARROW_WIDTH, height: HERO_NARROW_HEIGHT, fitWidth: HERO_NARROW_FIT_WIDTH, fitHeight: HERO_NARROW_FIT_HEIGHT }
];
type OverviewVariantResult = {
url: string;
zoom: number;
center: [number, number];
outName: string;
};
async function processOverview( async function processOverview(
hikes: HikeManifestEntry[] hikes: HikeManifestEntry[]
): Promise<HikesOverview | undefined> { ): Promise<HikesOverview | undefined> {
@@ -637,68 +700,92 @@ async function processOverview(
if (!Number.isFinite(minLat)) return undefined; if (!Number.isFinite(minLat)) return undefined;
const bbox: [number, number, number, number] = [minLat, minLng, maxLat, maxLng]; const bbox: [number, number, number, number] = [minLat, minLng, maxLat, maxLng];
const pose = computeStaticMapPose({
bbox,
width: HERO_WIDTH,
height: HERO_HEIGHT,
paddingPx: OVERVIEW_PADDING_PX,
fitWidth: OVERVIEW_FIT_WIDTH,
fitHeight: OVERVIEW_FIT_HEIGHT,
maxZoom: OVERVIEW_MAX_ZOOM
});
if (!pose) return undefined;
const hash = crypto
.createHash('sha256')
.update(
JSON.stringify({
bbox,
w: HERO_WIDTH,
h: HERO_HEIGHT,
lines,
maxZoom: OVERVIEW_MAX_ZOOM,
pad: OVERVIEW_PADDING_PX,
v: OVERVIEW_RENDER_VERSION
})
)
.digest('hex')
.slice(0, 8);
// Slug "_overview" picks up the same vite dev-server image plugin and
// nginx public-serve rules as per-hike assets, without colliding with
// any real hike slug (leading underscore is not a valid slug character).
const slug = '_overview'; const slug = '_overview';
const outName = `overview.${hash}.webp`;
const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images'); const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images');
await fs.mkdir(outDir, { recursive: true }); await fs.mkdir(outDir, { recursive: true });
const outPath = path.join(outDir, outName);
const renderT0 = Date.now(); async function renderVariant(spec: OverviewVariantSpec): Promise<OverviewVariantResult | undefined> {
console.log( const pose = computeStaticMapPose({
`[build-hikes:_overview] ${lines.length} polylines · zoom ${pose.zoom} · ` + bbox,
`${Math.round(HERO_WIDTH / 256)}×${Math.round(HERO_HEIGHT / 256)} tile grid` width: spec.width,
); height: spec.height,
if (!(await pathExists(outPath))) { paddingPx: OVERVIEW_PADDING_PX,
const ok = await renderOverviewMap({ fitWidth: spec.fitWidth,
pose, fitHeight: spec.fitHeight,
polylines: lines, maxZoom: OVERVIEW_MAX_ZOOM
outputPath: outPath,
width: HERO_WIDTH,
height: HERO_HEIGHT
}); });
if (!ok) { if (!pose) return undefined;
console.warn(`[build-hikes:_overview] render failed — too few tiles fetched`);
return undefined; const hash = crypto
.createHash('sha256')
.update(
JSON.stringify({
bbox,
w: spec.width,
h: spec.height,
fw: spec.fitWidth,
fh: spec.fitHeight,
lines,
maxZoom: OVERVIEW_MAX_ZOOM,
pad: OVERVIEW_PADDING_PX,
v: OVERVIEW_RENDER_VERSION
})
)
.digest('hex')
.slice(0, 8);
// `wide` keeps the historical `overview.<hash>.webp` filename to
// preserve existing caches.
const outName = spec.name === 'wide' ? `overview.${hash}.webp` : `overview-${spec.name}.${hash}.webp`;
const outPath = path.join(outDir, outName);
const renderT0 = Date.now();
console.log(
`[build-hikes:_overview] ${spec.name}: ${lines.length} polylines · zoom ${pose.zoom} · ` +
`${Math.round(spec.width / 256)}×${Math.round(spec.height / 256)} tile grid`
);
if (!(await pathExists(outPath))) {
const ok = await renderOverviewMap({
pose,
polylines: lines,
outputPath: outPath,
width: spec.width,
height: spec.height
});
if (!ok) {
console.warn(`[build-hikes:_overview] ${spec.name} render failed — too few tiles fetched`);
return undefined;
}
console.log(`[build-hikes:_overview] ${spec.name} rendered ${outName} in ${Date.now() - renderT0}ms`);
} else {
console.log(`[build-hikes:_overview] ${spec.name} cached (${outName})`);
} }
console.log(`[build-hikes:_overview] rendered ${outName} in ${Date.now() - renderT0}ms`);
} else { return {
console.log(`[build-hikes:_overview] cached (${outName})`); url: `/hikes/${slug}/images/${outName}`,
zoom: pose.zoom,
center: [pose.centerLat, pose.centerLng],
outName
};
} }
// Sweep orphan overview heroes from previous builds. const results = await Promise.all(OVERVIEW_VARIANT_SPECS.map(renderVariant));
const byVariant: Partial<Record<HeroVariant, OverviewVariantResult>> = {};
for (let i = 0; i < OVERVIEW_VARIANT_SPECS.length; i++) {
const r = results[i];
if (r) byVariant[OVERVIEW_VARIANT_SPECS[i].name] = r;
}
if (!byVariant.wide) return undefined;
// Sweep orphan overview heroes from previous builds. Keep both wide
// and narrow outNames if present.
const keep = new Set<string>();
for (const r of Object.values(byVariant)) {
if (r) keep.add(r.outName);
}
try { try {
const existing = await fs.readdir(outDir); const existing = await fs.readdir(outDir);
const orphans = existing.filter((f) => f !== outName); const orphans = existing.filter((f) => !keep.has(f));
if (orphans.length > 0) { if (orphans.length > 0) {
await Promise.all(orphans.map((f) => fs.unlink(path.join(outDir, f)).catch(() => {}))); await Promise.all(orphans.map((f) => fs.unlink(path.join(outDir, f)).catch(() => {})));
console.log(`[build-hikes:_overview] removed ${orphans.length} orphaned file(s)`); console.log(`[build-hikes:_overview] removed ${orphans.length} orphaned file(s)`);
@@ -708,28 +795,30 @@ async function processOverview(
} }
return { return {
url: `/hikes/${slug}/images/${outName}`, url: byVariant.wide.url,
zoom: pose.zoom, zoom: byVariant.wide.zoom,
center: [pose.centerLat, pose.centerLng] center: byVariant.wide.center,
urlNarrow: byVariant.narrow?.url,
zoomNarrow: byVariant.narrow?.zoom,
centerNarrow: byVariant.narrow?.center
}; };
} }
type HeroVariantResult = {
lightUrl: string;
lightOutName: string;
darkUrl: string;
darkOutName: string;
zoom: number;
center: [number, number];
};
async function processHero( async function processHero(
slug: string, slug: string,
track: GpxPoint[], track: GpxPoint[],
bbox: [number, number, number, number], bbox: [number, number, number, number],
imagePoints: ImagePoint[] imagePoints: ImagePoint[]
): Promise< ): Promise<Partial<Record<HeroVariant, HeroVariantResult>> | undefined> {
| {
lightUrl: string;
lightOutName: string;
darkUrl: string;
darkOutName: string;
zoom: number;
center: [number, number];
}
| undefined
> {
if (track.length < 2) return undefined; if (track.length < 2) return undefined;
const polyline: Array<[number, number]> = track.map((p) => [p.lat, p.lng]); const polyline: Array<[number, number]> = track.map((p) => [p.lat, p.lng]);
@@ -740,83 +829,98 @@ async function processHero(
.filter((ip) => ip.visibility !== 'private') .filter((ip) => ip.visibility !== 'private')
.map((ip) => ({ lat: ip.lat, lng: ip.lng })); .map((ip) => ({ lat: ip.lat, lng: ip.lng }));
// Pose (zoom + centre + canvas origin) is shared by both theme variants
// so they align pixel-perfectly. Computed once up-front; renders below
// reuse it. `fitWidth × fitHeight` pin the chosen zoom to what
// Leaflet's `fitBounds` picks on a typical desktop hero, so the full
// route is visible inside the static image even though the rendered
// canvas is much larger.
const pose = computeStaticMapPose({
bbox,
width: HERO_WIDTH,
height: HERO_HEIGHT,
fitWidth: HERO_FIT_WIDTH,
fitHeight: HERO_FIT_HEIGHT
});
if (!pose) return undefined;
const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images'); const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images');
await fs.mkdir(outDir, { recursive: true }); await fs.mkdir(outDir, { recursive: true });
// Per-theme hash + render. Theme is part of the hash so light and dark // One pose per viewport variant — narrow uses a phone-sized fit so the
// produce distinct filenames; both variants regenerate whenever the // chosen integer zoom matches what Leaflet picks at the same container
// route, photo set, or renderer version changes. // size, eliminating the visible "the static is too zoomed in" mismatch
async function renderVariant(theme: 'light' | 'dark'): Promise<{ url: string; outName: string } | undefined> { // the user sees with only a desktop-sized pose.
const fillColor = theme === 'dark' ? HERO_BADGE_FILL_DARK : HERO_BADGE_FILL_LIGHT; async function renderForViewport(
const borderColor = theme === 'dark' ? HERO_BADGE_BORDER_DARK : HERO_BADGE_BORDER_LIGHT; spec: (typeof HERO_VARIANT_SPECS)[number]
const iconColor = theme === 'dark' ? HERO_BADGE_ICON_DARK : HERO_BADGE_ICON_LIGHT; ): Promise<HeroVariantResult | undefined> {
const hash = crypto const pose = computeStaticMapPose({
.createHash('sha256') bbox,
.update( width: spec.width,
JSON.stringify({ height: spec.height,
bbox, fitWidth: spec.fitWidth,
w: HERO_WIDTH, fitHeight: spec.fitHeight
h: HERO_HEIGHT, });
if (!pose) return undefined;
async function renderTheme(theme: 'light' | 'dark'): Promise<{ url: string; outName: string } | undefined> {
const fillColor = theme === 'dark' ? HERO_BADGE_FILL_DARK : HERO_BADGE_FILL_LIGHT;
const borderColor = theme === 'dark' ? HERO_BADGE_BORDER_DARK : HERO_BADGE_BORDER_LIGHT;
const iconColor = theme === 'dark' ? HERO_BADGE_ICON_DARK : HERO_BADGE_ICON_LIGHT;
const hash = crypto
.createHash('sha256')
.update(
JSON.stringify({
bbox,
w: spec.width,
h: spec.height,
fw: spec.fitWidth,
fh: spec.fitHeight,
color: HERO_TRAIL_COLOR,
poly: polyline,
photos: photoMarkers,
fill: fillColor,
border: borderColor,
icon: iconColor,
v: HERO_RENDER_VERSION
})
)
.digest('hex')
.slice(0, 8);
// `wide` keeps the historical `hero-{theme}.<hash>.webp` filename
// so existing on-disk caches survive the variant split.
const prefix = spec.name === 'wide' ? `hero-${theme}` : `hero-${spec.name}-${theme}`;
const outName = `${prefix}.${hash}.webp`;
const outPath = path.join(outDir, outName);
if (!(await pathExists(outPath))) {
const ok = await renderStaticMap({
pose,
polyline,
color: HERO_TRAIL_COLOR, color: HERO_TRAIL_COLOR,
poly: polyline, outputPath: outPath,
photos: photoMarkers, width: spec.width,
fill: fillColor, height: spec.height,
border: borderColor, photoMarkers,
icon: iconColor, photoMarkerColor: fillColor,
v: HERO_RENDER_VERSION photoMarkerBorderColor: borderColor,
}) photoMarkerIconColor: iconColor
) });
.digest('hex') if (!ok) return undefined;
.slice(0, 8); }
const outName = `hero-${theme}.${hash}.webp`; return { url: `/hikes/${slug}/images/${outName}`, outName };
const outPath = path.join(outDir, outName);
if (!(await pathExists(outPath))) {
const ok = await renderStaticMap({
pose,
polyline,
color: HERO_TRAIL_COLOR,
outputPath: outPath,
width: HERO_WIDTH,
height: HERO_HEIGHT,
photoMarkers,
photoMarkerColor: fillColor,
photoMarkerBorderColor: borderColor,
photoMarkerIconColor: iconColor
});
if (!ok) return undefined;
} }
return { url: `/hikes/${slug}/images/${outName}`, outName }; const [light, dark] = await Promise.all([renderTheme('light'), renderTheme('dark')]);
if (!light || !dark) return undefined;
return {
lightUrl: light.url,
lightOutName: light.outName,
darkUrl: dark.url,
darkOutName: dark.outName,
zoom: pose.zoom,
center: [pose.centerLat, pose.centerLng]
};
} }
const [light, dark] = await Promise.all([renderVariant('light'), renderVariant('dark')]); const variants = await Promise.all(HERO_VARIANT_SPECS.map(renderForViewport));
if (!light || !dark) return undefined; const out: Partial<Record<HeroVariant, HeroVariantResult>> = {};
for (let i = 0; i < HERO_VARIANT_SPECS.length; i++) {
return { const v = variants[i];
lightUrl: light.url, if (v) out[HERO_VARIANT_SPECS[i].name] = v;
lightOutName: light.outName, }
darkUrl: dark.url, // At minimum we need the wide variant — that's what desktop falls back
darkOutName: dark.outName, // to, and CLS-reservation on the page expects it. Narrow is best-effort.
zoom: pose.zoom, if (!out.wide) return undefined;
center: [pose.centerLat, pose.centerLng] return out;
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -985,8 +1089,11 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
]); ]);
if (iconResult) keepFiles.images.add(iconResult.outName); if (iconResult) keepFiles.images.add(iconResult.outName);
if (heroResult) { if (heroResult) {
keepFiles.images.add(heroResult.lightOutName); for (const v of Object.values(heroResult)) {
keepFiles.images.add(heroResult.darkOutName); if (!v) continue;
keepFiles.images.add(v.lightOutName);
keepFiles.images.add(v.darkOutName);
}
} }
// Cleanup pass: drop any encoded files in either segment dir that don't // Cleanup pass: drop any encoded files in either segment dir that don't
@@ -1050,10 +1157,16 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
const tags = Array.isArray(fm.tags) ? fm.tags : []; const tags = Array.isArray(fm.tags) ? fm.tags : [];
const iconUrl = iconResult?.url; const iconUrl = iconResult?.url;
const heroMapUrlLight = heroResult?.lightUrl; const heroWide = heroResult?.wide;
const heroMapUrlDark = heroResult?.darkUrl; const heroNarrow = heroResult?.narrow;
const heroMapZoom = heroResult?.zoom; const heroMapUrlLight = heroWide?.lightUrl;
const heroMapCenter = heroResult?.center; const heroMapUrlDark = heroWide?.darkUrl;
const heroMapZoom = heroWide?.zoom;
const heroMapCenter = heroWide?.center;
const heroMapUrlLightNarrow = heroNarrow?.lightUrl;
const heroMapUrlDarkNarrow = heroNarrow?.darkUrl;
const heroMapZoomNarrow = heroNarrow?.zoom;
const heroMapCenterNarrow = heroNarrow?.center;
const entry: HikeManifestEntry = { const entry: HikeManifestEntry = {
slug, slug,
@@ -1085,6 +1198,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
heroMapUrlDark, heroMapUrlDark,
heroMapZoom, heroMapZoom,
heroMapCenter, heroMapCenter,
heroMapUrlLightNarrow,
heroMapUrlDarkNarrow,
heroMapZoomNarrow,
heroMapCenterNarrow,
imagePoints imagePoints
}; };
+54 -7
View File
@@ -15,6 +15,32 @@
// page's hero map. // page's hero map.
let heroMapReady = $state(false); let heroMapReady = $state(false);
// Phone vs. desktop viewport switch — drives which pre-rendered pose
// (`HIKES_OVERVIEW.zoom/center` vs. `.zoomNarrow/.centerNarrow`) we
// hand to Leaflet's first `setView` so it lands aligned with whichever
// static `<img>` the CSS is showing. Starts `false` to match SSR (which
// has no window); the $effect snaps it to the real value on mount and
// keeps it in sync if the user rotates / resizes across the breakpoint.
let narrowViewport = $state(false);
$effect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(max-width: 560px)');
narrowViewport = mq.matches;
const onChange = (e: MediaQueryListEvent) => {
narrowViewport = e.matches;
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
});
const overviewPose = $derived.by(() => {
if (!HIKES_OVERVIEW) return null;
if (narrowViewport && HIKES_OVERVIEW.urlNarrow && HIKES_OVERVIEW.centerNarrow && typeof HIKES_OVERVIEW.zoomNarrow === 'number') {
return { center: HIKES_OVERVIEW.centerNarrow, zoom: HIKES_OVERVIEW.zoomNarrow };
}
return { center: HIKES_OVERVIEW.center, zoom: HIKES_OVERVIEW.zoom };
});
// Filter ceilings start wide-open so the initial render (SSR + first // Filter ceilings start wide-open so the initial render (SSR + first
// hydration pass) shows every hike. `$effect` below clamps them down // hydration pass) shows every hike. `$effect` below clamps them down
// to the actual data maxes once `data.hikes` is fully populated — // to the actual data maxes once `data.hikes` is fully populated —
@@ -91,12 +117,12 @@
visible hike's preview polyline, coloured by SAC tier. visible hike's preview polyline, coloured by SAC tier.
Displayed at native pixel size (`object-fit: none`) so it Displayed at native pixel size (`object-fit: none`) so it
overlays Leaflet's live tiles exactly. The image fades out overlays Leaflet's live tiles exactly. The image fades out
once Leaflet's first tile batch loads. Unlike the detail once Leaflet's first tile batch loads. Two width variants
hero, the overview map looks the same in light and dark ship — desktop (wide pose) and phone (narrow pose, ≤560 CSS
mode (only the per-hike camera badges are theme-aware, px). CSS chooses which one shows based on a media query so
and the overview has none) so a single variant ships. --> hydration doesn't need to wait. -->
<img <img
class="hero-static" class="hero-static hero-static-wide"
class:faded={heroMapReady} class:faded={heroMapReady}
src={HIKES_OVERVIEW.url} src={HIKES_OVERVIEW.url}
alt="" alt=""
@@ -104,11 +130,22 @@
loading="eager" loading="eager"
decoding="async" decoding="async"
/> />
{#if HIKES_OVERVIEW.urlNarrow}
<img
class="hero-static hero-static-narrow"
class:faded={heroMapReady}
src={HIKES_OVERVIEW.urlNarrow}
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
/>
{/if}
{/if} {/if}
<HikesOverviewMap <HikesOverviewMap
hikes={visible} hikes={visible}
initialCenter={HIKES_OVERVIEW?.center} initialCenter={overviewPose?.center}
initialZoom={HIKES_OVERVIEW?.zoom} initialZoom={overviewPose?.zoom}
onReady={() => (heroMapReady = true)} onReady={() => (heroMapReady = true)}
/> />
</section> </section>
@@ -198,6 +235,16 @@
opacity: 0; opacity: 0;
} }
/* Wide ↔ narrow viewport swap. The narrow variant is rendered at a
* phone-sized fit, so the zoom matches what Leaflet picks at the same
* container width — without this the desktop hero would land too
* zoomed-in on phones (its pose was chosen for ~1920 CSS px). */
.hero-static-narrow { display: none; }
@media (max-width: 560px) {
.hero-static-wide { display: none; }
.hero-static-narrow { display: block; }
}
/* Live overview map sits above the static; transparent so the static /* Live overview map sits above the static; transparent so the static
* shows through until Leaflet's tile pane paints over it. */ * shows through until Leaflet's tile pane paints over it. */
.hero-map :global(.overview-map) { .hero-map :global(.overview-map) {
+105 -22
View File
@@ -31,6 +31,38 @@
// handover is a soft cross-fade rather than a swap. // handover is a soft cross-fade rather than a swap.
let heroMapReady = $state(false); let heroMapReady = $state(false);
// Phone vs. desktop viewport — 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` for SSR; the $effect snaps
// it to the real value on mount and keeps it in sync across rotate /
// resize. See `/hikes/+page.svelte` for the matching overview-side
// pattern.
let narrowViewport = $state(false);
$effect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(max-width: 560px)');
narrowViewport = mq.matches;
const onChange = (e: MediaQueryListEvent) => {
narrowViewport = e.matches;
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
});
const heroPose = $derived.by(() => {
if (
narrowViewport &&
hike.heroMapCenterNarrow &&
typeof hike.heroMapZoomNarrow === 'number'
) {
return { center: hike.heroMapCenterNarrow, zoom: hike.heroMapZoomNarrow };
}
if (hike.heroMapCenter && typeof hike.heroMapZoom === 'number') {
return { center: hike.heroMapCenter, zoom: hike.heroMapZoom };
}
return null;
});
$effect(() => { $effect(() => {
let aborted = false; let aborted = false;
fetch(hike.trackUrl) fetch(hike.trackUrl)
@@ -233,15 +265,16 @@
<section class="hero-map" style="view-transition-name: hike-{hike.slug}"> <section class="hero-map" style="view-transition-name: hike-{hike.slug}">
{#if hike.heroMapUrlLight} {#if hike.heroMapUrlLight}
<!-- Build-time static composite of Swisstopo tiles + the trail <!-- Build-time static composite of Swisstopo tiles + the trail
polyline + public photo markers. Two variants ship (light polyline + public photo markers. Four variants ship — theme
and dark); CSS picks the right one based on `data-theme` (light/dark) × viewport (wide/narrow). Theme is picked by
with `prefers-color-scheme` as the fallback. Both are `data-theme` / `prefers-color-scheme`; viewport by a
rendered at the same zoom + centre as the live Leaflet `max-width: 560px` media query. Each variant is rendered at
map, and displayed at native pixel size (object-fit: none) the same pose Leaflet's `fitBounds` picks for its target
so they overlay the live tiles exactly. The image fades container size, so the static→live cross-fade aligns
out once Leaflet's first tile batch loads. --> pixel-perfectly. The image fades out once Leaflet's first
tile batch loads. -->
<img <img
class="hero-static hero-static-light" class="hero-static hero-static-light hero-static-wide"
class:faded={heroMapReady} class:faded={heroMapReady}
src={hike.heroMapUrlLight} src={hike.heroMapUrlLight}
alt="" alt=""
@@ -252,7 +285,7 @@
{/if} {/if}
{#if hike.heroMapUrlDark} {#if hike.heroMapUrlDark}
<img <img
class="hero-static hero-static-dark" class="hero-static hero-static-dark hero-static-wide"
class:faded={heroMapReady} class:faded={heroMapReady}
src={hike.heroMapUrlDark} src={hike.heroMapUrlDark}
alt="" alt=""
@@ -261,18 +294,40 @@
decoding="async" decoding="async"
/> />
{/if} {/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} {#if track && track.length > 0}
<HikeMap <HikeMap
{track} {track}
imagePoints={visibleImagePoints} imagePoints={visibleImagePoints}
showPrivate showPrivate
initialCenter={hike.heroMapCenter} initialCenter={heroPose?.center}
initialZoom={hike.heroMapZoom} initialZoom={heroPose?.zoom}
onReady={() => (heroMapReady = true)} onReady={() => (heroMapReady = true)}
/> />
{:else if trackError} {:else if trackError}
<div class="map-fallback">Track konnte nicht geladen werden: {trackError}</div> <div class="map-fallback">Track konnte nicht geladen werden: {trackError}</div>
{:else if !hike.heroMapUrl} {:else if !hike.heroMapUrlLight}
<div class="map-fallback">Track wird geladen…</div> <div class="map-fallback">Track wird geladen…</div>
{/if} {/if}
<div class="hero-title"> <div class="hero-title">
@@ -447,18 +502,46 @@
pointer-events: none; pointer-events: none;
} }
/* Theme-aware switch between the two pre-rendered variants. Default /* 2×2 picker: theme (light/dark) × viewport (wide/narrow). Each `<img>`
* is light; `prefers-color-scheme: dark` flips it; an explicit * has both qualifiers (e.g. `.hero-static-light.hero-static-wide`); we
* `data-theme` attribute on `<html>` always wins (higher specificity). */ * hide everything by default and reveal exactly one based on the
.hero-static-dark { display: none; } * active theme and the `max-width: 560px` media query. The narrow
* variant uses a phone-sized pose so the auto-fit zoom matches what
* Leaflet picks at the same container width. */
.hero-static { display: none; }
/* Default (light theme assumed, no `data-theme` attribute, no
* `prefers-color-scheme: dark`): show the wide-light variant. */
.hero-static-light.hero-static-wide { display: block; }
@media (max-width: 560px) {
.hero-static-light.hero-static-wide { display: none; }
.hero-static-light.hero-static-narrow { display: block; }
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.hero-static-light { display: none; } .hero-static-light.hero-static-wide,
.hero-static-dark { display: block; } .hero-static-light.hero-static-narrow { display: none; }
.hero-static-dark.hero-static-wide { display: block; }
@media (max-width: 560px) {
.hero-static-dark.hero-static-wide { 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: 560px) {
:global(:root[data-theme='light']) .hero-static-light.hero-static-wide { 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: 560px) {
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-wide { display: none; }
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-narrow { display: block; }
} }
:global(:root[data-theme='light']) .hero-static-light { display: block; }
:global(:root[data-theme='light']) .hero-static-dark { display: none; }
:global(:root[data-theme='dark']) .hero-static-light { display: none; }
:global(:root[data-theme='dark']) .hero-static-dark { display: block; }
/* Push Leaflet's top-left controls (zoom +/-) below the sticky nav so /* Push Leaflet's top-left controls (zoom +/-) below the sticky nav so
+24 -6
View File
@@ -81,8 +81,7 @@ export type HikeManifestEntry = {
* arrives. */ * arrives. */
heroMapUrlLight?: string; heroMapUrlLight?: string;
/** Pre-rendered hero map (dark theme). Same pose as /** Pre-rendered hero map (dark theme). Same pose as
* `heroMapUrlLight` but with the tile composite passed through an * `heroMapUrlLight` but with theme-appropriate photo-marker colours. */
* `invert(1) hue-rotate(180deg)` filter so the map fits the dark UI. */
heroMapUrlDark?: string; heroMapUrlDark?: string;
/** Zoom level the static hero was rendered at. Leaflet uses this with /** Zoom level the static hero was rendered at. Leaflet uses this with
* `heroMapCenter` to land on the exact same view on first paint, so * `heroMapCenter` to land on the exact same view on first paint, so
@@ -91,6 +90,17 @@ export type HikeManifestEntry = {
/** Map centre `[lat, lng]` the static hero was rendered around. */ /** Map centre `[lat, lng]` the static hero was rendered around. */
heroMapCenter?: [number, number]; heroMapCenter?: [number, number];
/** Narrow-viewport variant of the pre-rendered hero (≤ 560 CSS px).
* Rendered with a phone-sized `fitWidth`/`fitHeight`, so the chosen
* integer zoom matches what Leaflet's `fitBounds` picks at the same
* container size. The page shows these instead of the wide variants
* on small screens, otherwise the wide hero would land too zoomed-in
* (its pose was chosen for a 1920×640 desktop hero). */
heroMapUrlLightNarrow?: string;
heroMapUrlDarkNarrow?: string;
heroMapZoomNarrow?: number;
heroMapCenterNarrow?: [number, number];
// Geo-tagged photos shown as map markers on the detail page: // Geo-tagged photos shown as map markers on the detail page:
imagePoints: ImagePoint[]; imagePoints: ImagePoint[];
}; };
@@ -100,11 +110,19 @@ export type HikeManifestEntry = {
* shows it under the sticky nav until Leaflet's first tile batch loads, * shows it under the sticky nav until Leaflet's first tile batch loads,
* then fades it out — same handover pattern as the per-hike detail hero. */ * then fades it out — same handover pattern as the per-hike detail hero. */
export type HikesOverview = { export type HikesOverview = {
/** Absolute URL of the pre-rendered WebP. */ /** Absolute URL of the wide (desktop) pre-rendered WebP. */
url: string; url: string;
/** Integer zoom the static was rendered at (matches Leaflet's /** Integer zoom the wide static was rendered at (matches Leaflet's
* `fitBounds(unionBounds, { padding: 32, maxZoom: 13 })` choice). */ * `fitBounds(unionBounds, { padding: 32, maxZoom: 13 })` choice on a
* desktop-sized container). */
zoom: number; zoom: number;
/** Centre `[lat, lng]` the static was rendered around. */ /** Centre `[lat, lng]` the wide static was rendered around. */
center: [number, number]; center: [number, number];
/** Narrow-viewport variant for phones (≤ 560 CSS px). Rendered at a
* phone-sized `fitWidth`/`fitHeight`, so the chosen zoom matches what
* Leaflet picks at the same container size. The page shows it instead
* of `url` on small screens. */
urlNarrow?: string;
zoomNarrow?: number;
centerNarrow?: [number, number];
}; };