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.
This commit is contained in:
2026-05-26 11:51:48 +02:00
parent b49a299371
commit 8a67f5fba8
8 changed files with 237 additions and 173 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.90.0", "version": "1.91.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+67 -49
View File
@@ -29,7 +29,7 @@ import {
type GpxStage type GpxStage
} from '../src/lib/server/gpx.js'; } from '../src/lib/server/gpx.js';
import { simplifyTrack } from '../src/lib/server/simplifyTrack.js'; import { simplifyTrack } from '../src/lib/server/simplifyTrack.js';
import { computeStaticMapPose, renderOverviewMap, renderStaticMap } from './staticHikeMap.js'; import { computeStaticMapPose, lngLatToPx, renderOverviewMap, renderStaticMap } from './staticHikeMap.js';
import { sacTrailColor, SAC_TRAIL_COLOR } from '../src/lib/data/sacColors.js'; import { sacTrailColor, SAC_TRAIL_COLOR } from '../src/lib/data/sacColors.js';
import { computeElevationStats, computeElevationRange } from '../src/lib/hikes/elevation.js'; import { computeElevationStats, computeElevationRange } from '../src/lib/hikes/elevation.js';
import type { import type {
@@ -692,7 +692,17 @@ const HERO_NARROW_HEIGHT = 1200;
const HERO_NARROW_FIT_WIDTH = 400; const HERO_NARROW_FIT_WIDTH = 400;
const HERO_NARROW_FIT_HEIGHT = 480; const HERO_NARROW_FIT_HEIGHT = 480;
type HeroVariant = 'wide' | 'narrow'; // Medium-viewport variant for the 561899 px CSS width band (tablets, split
// panes, small laptops). Picks an in-between pose so the auto-fit zoom
// matches what Leaflet computes at tablet widths — without this the wide
// hero (chosen for ~1920 CSS px) lands too zoomed-in on tablets, and the
// narrow hero (chosen for ~400 CSS px) lands too zoomed-out.
const HERO_MEDIUM_WIDTH = 2400;
const HERO_MEDIUM_HEIGHT = 1500;
const HERO_MEDIUM_FIT_WIDTH = 1000;
const HERO_MEDIUM_FIT_HEIGHT = 500;
type HeroVariant = 'wide' | 'medium' | 'narrow';
const HERO_VARIANT_SPECS: ReadonlyArray<{ const HERO_VARIANT_SPECS: ReadonlyArray<{
name: HeroVariant; name: HeroVariant;
@@ -708,6 +718,13 @@ const HERO_VARIANT_SPECS: ReadonlyArray<{
fitWidth: HERO_FIT_WIDTH, fitWidth: HERO_FIT_WIDTH,
fitHeight: HERO_FIT_HEIGHT fitHeight: HERO_FIT_HEIGHT
}, },
{
name: 'medium',
width: HERO_MEDIUM_WIDTH,
height: HERO_MEDIUM_HEIGHT,
fitWidth: HERO_MEDIUM_FIT_WIDTH,
fitHeight: HERO_MEDIUM_FIT_HEIGHT
},
{ {
name: 'narrow', name: 'narrow',
width: HERO_NARROW_WIDTH, width: HERO_NARROW_WIDTH,
@@ -717,32 +734,23 @@ const HERO_VARIANT_SPECS: ReadonlyArray<{
} }
]; ];
// Padding + max-zoom match the live overview map's // Bump when the overview renderer's output changes — e.g. stroke widths,
// `fitBounds(..., { padding: [32, 32], maxZoom: 13 })` so the static lands // palette tweaks, framing constants in `processOverview`.
// at the same pose Leaflet will fit to. fitHeight matches the page's const OVERVIEW_RENDER_VERSION = 2;
// `clamp(320px, 50vh, 520px)` hero at desktop viewports.
const OVERVIEW_FIT_WIDTH = 1920;
const OVERVIEW_FIT_HEIGHT = 520;
const OVERVIEW_PADDING_PX = 32;
const OVERVIEW_MAX_ZOOM = 13;
// Bump alongside `HERO_RENDER_VERSION` (or independently) when the overview
// renderer's output changes — e.g. stroke widths, palette tweaks.
const OVERVIEW_RENDER_VERSION = 1;
type OverviewVariantSpec = { type OverviewVariantSpec = {
name: HeroVariant; name: HeroVariant;
width: number; width: number;
height: number; height: number;
fitWidth: number;
fitHeight: number;
}; };
// Overview narrow uses the same canvas dims as the per-hike narrow but // One spec per viewport band; the overview's per-variant zoom is fixed in
// fits the union bbox at phone size — same `maxZoom: 13` clamp as the // `processOverview` rather than being derived from a fit bbox, so we only
// live map's `fitBounds`. // need to know the output canvas size here.
const OVERVIEW_VARIANT_SPECS: ReadonlyArray<OverviewVariantSpec> = [ const OVERVIEW_VARIANT_SPECS: ReadonlyArray<OverviewVariantSpec> = [
{ name: 'wide', width: HERO_WIDTH, height: HERO_HEIGHT, fitWidth: OVERVIEW_FIT_WIDTH, fitHeight: OVERVIEW_FIT_HEIGHT }, { name: 'wide', width: HERO_WIDTH, height: HERO_HEIGHT },
{ name: 'narrow', width: HERO_NARROW_WIDTH, height: HERO_NARROW_HEIGHT, fitWidth: HERO_NARROW_FIT_WIDTH, fitHeight: HERO_NARROW_FIT_HEIGHT } { name: 'medium', width: HERO_MEDIUM_WIDTH, height: HERO_MEDIUM_HEIGHT },
{ name: 'narrow', width: HERO_NARROW_WIDTH, height: HERO_NARROW_HEIGHT }
]; ];
type OverviewVariantResult = { type OverviewVariantResult = {
@@ -764,49 +772,47 @@ async function processOverview(
})); }));
if (lines.length === 0) return undefined; if (lines.length === 0) return undefined;
// Union bbox over every hike's bbox — that's what Leaflet's // Frame on Switzerland, not the union of hike bboxes — the overview
// `fitBounds(bounds)` operates on with `extend()` per polyline. Using // reads as "hikes across CH" instead of zooming in on whichever corner
// each hike's bbox rather than every polyline point keeps the math // the catalogue clusters in. Center is the country's approximate
// cheap without losing the framing accuracy. // geographic midpoint; zoom is picked per-variant so CH fills the
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; // hero at each viewport without the country bbox having to fit
for (const h of hikes) { // pixel-perfectly inside `OVERVIEW_FIT_*` (which would force the wide
const [a, b, c, d] = h.bbox; // variant down to z=7 — too far out). At these zooms the rendered
if (a < minLat) minLat = a; // canvas slightly overflows the visible hero on the short axis, and
if (c > maxLat) maxLat = c; // `object-fit: none` crops to the centre — exactly what we want for
if (b < minLng) minLng = b; // a "frame on the country" composition.
if (d > maxLng) maxLng = d; const CH_CENTER: [number, number] = [46.82, 8.23];
} const OVERVIEW_ZOOM_BY_VARIANT: Record<HeroVariant, number> = {
if (!Number.isFinite(minLat)) return undefined; wide: 8,
const bbox: [number, number, number, number] = [minLat, minLng, maxLat, maxLng]; medium: 8,
narrow: 7
};
const slug = '_overview'; const slug = '_overview';
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 });
async function renderVariant(spec: OverviewVariantSpec): Promise<OverviewVariantResult | undefined> { async function renderVariant(spec: OverviewVariantSpec): Promise<OverviewVariantResult | undefined> {
const pose = computeStaticMapPose({ const zoom = OVERVIEW_ZOOM_BY_VARIANT[spec.name];
bbox, const c = lngLatToPx(CH_CENTER[1], CH_CENTER[0], zoom);
width: spec.width, const pose = {
height: spec.height, zoom,
paddingPx: OVERVIEW_PADDING_PX, centerLat: CH_CENTER[0],
fitWidth: spec.fitWidth, centerLng: CH_CENTER[1],
fitHeight: spec.fitHeight, originX: Math.round(c.x - spec.width / 2),
maxZoom: OVERVIEW_MAX_ZOOM originY: Math.round(c.y - spec.height / 2)
}); };
if (!pose) return undefined;
const hash = crypto const hash = crypto
.createHash('sha256') .createHash('sha256')
.update( .update(
JSON.stringify({ JSON.stringify({
bbox, center: CH_CENTER,
zoom,
w: spec.width, w: spec.width,
h: spec.height, h: spec.height,
fw: spec.fitWidth,
fh: spec.fitHeight,
lines, lines,
maxZoom: OVERVIEW_MAX_ZOOM,
pad: OVERVIEW_PADDING_PX,
v: OVERVIEW_RENDER_VERSION v: OVERVIEW_RENDER_VERSION
}) })
) )
@@ -877,6 +883,9 @@ async function processOverview(
url: byVariant.wide.url, url: byVariant.wide.url,
zoom: byVariant.wide.zoom, zoom: byVariant.wide.zoom,
center: byVariant.wide.center, center: byVariant.wide.center,
urlMedium: byVariant.medium?.url,
zoomMedium: byVariant.medium?.zoom,
centerMedium: byVariant.medium?.center,
urlNarrow: byVariant.narrow?.url, urlNarrow: byVariant.narrow?.url,
zoomNarrow: byVariant.narrow?.zoom, zoomNarrow: byVariant.narrow?.zoom,
centerNarrow: byVariant.narrow?.center centerNarrow: byVariant.narrow?.center
@@ -1388,11 +1397,16 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
const iconUrl = iconResult?.url; const iconUrl = iconResult?.url;
const heroWide = heroResult?.wide; const heroWide = heroResult?.wide;
const heroMedium = heroResult?.medium;
const heroNarrow = heroResult?.narrow; const heroNarrow = heroResult?.narrow;
const heroMapUrlLight = heroWide?.lightUrl; const heroMapUrlLight = heroWide?.lightUrl;
const heroMapUrlDark = heroWide?.darkUrl; const heroMapUrlDark = heroWide?.darkUrl;
const heroMapZoom = heroWide?.zoom; const heroMapZoom = heroWide?.zoom;
const heroMapCenter = heroWide?.center; const heroMapCenter = heroWide?.center;
const heroMapUrlLightMedium = heroMedium?.lightUrl;
const heroMapUrlDarkMedium = heroMedium?.darkUrl;
const heroMapZoomMedium = heroMedium?.zoom;
const heroMapCenterMedium = heroMedium?.center;
const heroMapUrlLightNarrow = heroNarrow?.lightUrl; const heroMapUrlLightNarrow = heroNarrow?.lightUrl;
const heroMapUrlDarkNarrow = heroNarrow?.darkUrl; const heroMapUrlDarkNarrow = heroNarrow?.darkUrl;
const heroMapZoomNarrow = heroNarrow?.zoom; const heroMapZoomNarrow = heroNarrow?.zoom;
@@ -1431,6 +1445,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
heroMapUrlDark, heroMapUrlDark,
heroMapZoom, heroMapZoom,
heroMapCenter, heroMapCenter,
heroMapUrlLightMedium,
heroMapUrlDarkMedium,
heroMapZoomMedium,
heroMapCenterMedium,
heroMapUrlLightNarrow, heroMapUrlLightNarrow,
heroMapUrlDarkNarrow, heroMapUrlDarkNarrow,
heroMapZoomNarrow, heroMapZoomNarrow,
+1 -1
View File
@@ -30,7 +30,7 @@ function tileUrl(sub: string, layer: string, z: number, x: number, y: number): s
} }
/** Web Mercator: lng/lat → absolute pixel coordinate at a given zoom. */ /** Web Mercator: lng/lat → absolute pixel coordinate at a given zoom. */
function lngLatToPx(lng: number, lat: number, zoom: number): { x: number; y: number } { export function lngLatToPx(lng: number, lat: number, zoom: number): { x: number; y: number } {
const n = 2 ** zoom; const n = 2 ** zoom;
const x = ((lng + 180) / 360) * n * TILE_SIZE; const x = ((lng + 180) / 360) * n * TILE_SIZE;
const latRad = (lat * Math.PI) / 180; const latRad = (lat * Math.PI) / 180;
+10 -43
View File
@@ -191,50 +191,17 @@
let currentBase: BaseLayer = 'schematic'; let currentBase: BaseLayer = 'schematic';
// First-paint handover: when the schematic tile layer finishes // First-paint handover: when the schematic tile layer finishes
// loading its initial batch, fire `onReady` (so the static hero // loading its initial batch, fire `onReady` so the static hero
// can fade out) AND — if we opened with `setView` to match a // can fade out. The map already opened at the static pose via
// pre-rendered hero — animate to Leaflet's natural `fitBounds` // `setView(initialCenter, initialZoom)` below, so the live
// view, which is typically slightly more zoomed out. The fade // tiles paint over the static at the same framing — no second
// (~450 ms) overlaps with the zoom animation (~700 ms) so the // animation is needed (and a `flyToBounds` here would actually
// user sees the map ease into its proper framing as the static // cause a visible wobble on hikes whose bbox sits right at an
// dissolves. // integer-zoom boundary, where the static's fit and Leaflet's
// runtime fit disagree by one zoom step at the user's actual
// container size).
tileLayers.schematic.once('load', () => { tileLayers.schematic.once('load', () => {
// Initial tiles at the static-hero pose are present. If we onReady?.();
// don't need to fly to a different framing, fire `onReady`
// straight away — that's the simple path.
if (!initialCenter || typeof initialZoom !== 'number') {
onReady?.();
return;
}
// Otherwise: start the fly-to-bounds animation while the
// static stays visible on top. Once the fly settles AND the
// new tiles for the final view are in (or a short safety
// window elapses), THEN fire `onReady` so the static fades
// out over fully-loaded tiles — no grey flash in the gap
// between fly-end and tile-load.
map.flyToBounds(initialBounds, {
padding: [24, 24],
duration: 0.9,
easeLinearity: 0.3
});
map.once('moveend', () => {
let fired = false;
const fire = () => {
if (fired) return;
fired = true;
onReady?.();
};
// `load` fires when every currently-visible tile has
// arrived. Usually that's the trigger.
tileLayers.schematic.once('load', fire);
// Safety net: if the fly didn't change zoom enough to
// require any new tiles, `load` may not re-fire. 350 ms
// is short enough to feel responsive, long enough for
// the post-fly tile batch to arrive on typical
// connections.
setTimeout(fire, 350);
});
}); });
// Canvas-rendered polylines can't resolve CSS custom properties, // Canvas-rendered polylines can't resolve CSS custom properties,
@@ -151,35 +151,17 @@
// union bounds. // union bounds.
let initialBounds: ReturnType<typeof L.latLngBounds> | null = null; let initialBounds: ReturnType<typeof L.latLngBounds> | null = null;
// First-paint handover: when the schematic tile layer finishes // First-paint handover: fire `onReady` once the schematic tile
// loading its initial batch, fire `onReady` (so the static hero // layer's initial batch loads so the static hero can fade out.
// can fade out) and — if we opened with `setView` to match a // The map already opened at the static pose via setView (see
// pre-rendered hero — animate to Leaflet's natural `fitBounds` // the initialCenter branch below), so no extra animation is
// of the union polyline bounds. The fade overlaps with the zoom // needed — and `flyToBounds(union)` here used to cause a
// animation so the user sees the map ease into its final // visible wobble on hikes whose union bbox sits at an integer-
// framing as the static dissolves. Mirrors the same pattern in // zoom boundary, where the static's fit and Leaflet's runtime
// fit disagree by one zoom step. Mirrors the same fix in
// `HikeMap.svelte`. // `HikeMap.svelte`.
tileLayers.schematic.once('load', () => { tileLayers.schematic.once('load', () => {
if (!initialCenter || typeof initialZoom !== 'number' || !initialBounds) { onReady?.();
onReady?.();
return;
}
map.flyToBounds(initialBounds, {
padding: [32, 32],
maxZoom: 13,
duration: 0.9,
easeLinearity: 0.3
});
map.once('moveend', () => {
let fired = false;
const fire = () => {
if (fired) return;
fired = true;
onReady?.();
};
tileLayers.schematic.once('load', fire);
setTimeout(fire, 350);
});
}); });
// One polyline per hike, sourced from the manifest's already- // One polyline per hike, sourced from the manifest's already-
+49 -20
View File
@@ -28,22 +28,30 @@
// 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 // Three-band viewport switch — drives which pre-rendered pose
// (`HIKES_OVERVIEW.zoom/center` vs. `.zoomNarrow/.centerNarrow`) we // (`HIKES_OVERVIEW.zoom/center` vs. `.zoomMedium/.centerMedium` vs.
// hand to Leaflet's first `setView` so it lands aligned with whichever // `.zoomNarrow/.centerNarrow`) we hand to Leaflet's first `setView` so
// static `<img>` the CSS is showing. Starts `false` to match SSR (which // it lands aligned with whichever static `<img>` the CSS is showing.
// has no window); the $effect snaps it to the real value on mount and // Starts `false`/`false` to match SSR (which has no window); the
// keeps it in sync if the user rotates / resizes across the breakpoint. // $effect snaps to the real value on mount and keeps both flags in
// sync across rotate/resize. `narrow` wins over `medium` when both
// would match (≤560 is also <900).
let narrowViewport = $state(false); let narrowViewport = $state(false);
let mediumViewport = $state(false);
$effect(() => { $effect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const mq = window.matchMedia('(max-width: 560px)'); const mqNarrow = window.matchMedia('(max-width: 560px)');
narrowViewport = mq.matches; const mqMedium = window.matchMedia('(min-width: 561px) and (max-width: 899px)');
const onChange = (e: MediaQueryListEvent) => { narrowViewport = mqNarrow.matches;
narrowViewport = e.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);
}; };
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}); });
const overviewPose = $derived.by(() => { const overviewPose = $derived.by(() => {
@@ -51,6 +59,9 @@
if (narrowViewport && HIKES_OVERVIEW.urlNarrow && HIKES_OVERVIEW.centerNarrow && typeof HIKES_OVERVIEW.zoomNarrow === 'number') { 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.centerNarrow, zoom: HIKES_OVERVIEW.zoomNarrow };
} }
if (mediumViewport && HIKES_OVERVIEW.urlMedium && HIKES_OVERVIEW.centerMedium && typeof HIKES_OVERVIEW.zoomMedium === 'number') {
return { center: HIKES_OVERVIEW.centerMedium, zoom: HIKES_OVERVIEW.zoomMedium };
}
return { center: HIKES_OVERVIEW.center, zoom: HIKES_OVERVIEW.zoom }; return { center: HIKES_OVERVIEW.center, zoom: HIKES_OVERVIEW.zoom };
}); });
@@ -193,9 +204,9 @@
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. Two width variants once Leaflet's first tile batch loads. Three width variants
ship — desktop (wide pose) and phone (narrow pose, ≤560 CSS ship — wide (≥900 CSS px), medium (561899), narrow (≤560).
px). CSS chooses which one shows based on a media query so CSS chooses which one shows based on a media query so
hydration doesn't need to wait. --> hydration doesn't need to wait. -->
<img <img
class="hero-static hero-static-wide" class="hero-static hero-static-wide"
@@ -206,6 +217,17 @@
loading="eager" loading="eager"
decoding="async" decoding="async"
/> />
{#if HIKES_OVERVIEW.urlMedium}
<img
class="hero-static hero-static-medium"
class:faded={heroMapReady}
src={HIKES_OVERVIEW.urlMedium}
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
/>
{/if}
{#if HIKES_OVERVIEW.urlNarrow} {#if HIKES_OVERVIEW.urlNarrow}
<img <img
class="hero-static hero-static-narrow" class="hero-static hero-static-narrow"
@@ -325,13 +347,20 @@
opacity: 0; opacity: 0;
} }
/* Wide ↔ narrow viewport swap. The narrow variant is rendered at a /* Three-band viewport swap (wide ≥900, medium 561899, narrow ≤560).
* phone-sized fit, so the zoom matches what Leaflet picks at the same * Each variant is rendered at a fit matching its band so Leaflet picks
* container width — without this the desktop hero would land too * the same zoom on first paint — without this the desktop hero would
* zoomed-in on phones (its pose was chosen for ~1920 CSS px). */ * land too zoomed-in on tablets/phones (its pose was chosen for
* ~1920 CSS px), and the narrow hero would land too zoomed-out on
* tablets (chosen for ~400 CSS px). */
.hero-static-medium,
.hero-static-narrow { display: none; } .hero-static-narrow { display: none; }
@media (max-width: 560px) { @media (max-width: 899px) {
.hero-static-wide { display: none; } .hero-static-wide { display: none; }
.hero-static-medium { display: block; }
}
@media (max-width: 560px) {
.hero-static-medium { display: none; }
.hero-static-narrow { display: block; } .hero-static-narrow { display: block; }
} }
+85 -32
View File
@@ -40,22 +40,29 @@
// 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 // Three-band viewport switch (narrow ≤560, medium 561899, wide ≥900)
// to Leaflet's first `setView` so it lands aligned with the static // — picks which pre-rendered pose we hand to Leaflet's first `setView`
// `<img>` the CSS is showing. Starts `false` for SSR; the $effect snaps // so it lands aligned with the static `<img>` the CSS is showing.
// it to the real value on mount and keeps it in sync across rotate / // Starts `false`/`false` for SSR; the $effect snaps to real values on
// resize. See `/hikes/+page.svelte` for the matching overview-side // mount and keeps both flags in sync across rotate/resize. `narrow`
// pattern. // wins over `medium` when both would match. See the matching overview
// pattern in `/hikes/+page.svelte`.
let narrowViewport = $state(false); let narrowViewport = $state(false);
let mediumViewport = $state(false);
$effect(() => { $effect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const mq = window.matchMedia('(max-width: 560px)'); const mqNarrow = window.matchMedia('(max-width: 560px)');
narrowViewport = mq.matches; const mqMedium = window.matchMedia('(min-width: 561px) and (max-width: 899px)');
const onChange = (e: MediaQueryListEvent) => { narrowViewport = mqNarrow.matches;
narrowViewport = e.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);
}; };
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}); });
const canton = $derived(resolveCanton(hike.canton)); const canton = $derived(resolveCanton(hike.canton));
@@ -84,6 +91,13 @@
) { ) {
return { center: hike.heroMapCenterNarrow, zoom: hike.heroMapZoomNarrow }; 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') { if (hike.heroMapCenter && typeof hike.heroMapZoom === 'number') {
return { center: hike.heroMapCenter, zoom: hike.heroMapZoom }; return { center: hike.heroMapCenter, zoom: hike.heroMapZoom };
} }
@@ -284,14 +298,14 @@
<section class="hero-map" style="view-transition-name: hike-{hike.slug}; view-transition-class: hike-fly-in"> <section class="hero-map" style="view-transition-name: hike-{hike.slug}; view-transition-class: hike-fly-in">
{#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. Four variants ship — theme polyline + public photo markers. Six variants ship — theme
(light/dark) × viewport (wide/narrow). Theme is picked by (light/dark) × viewport (wide ≥900 / medium 561899 /
`data-theme` / `prefers-color-scheme`; viewport by a narrow ≤560 CSS px). Theme is picked by `data-theme` /
`max-width: 560px` media query. Each variant is rendered at `prefers-color-scheme`; viewport by media queries. Each
the same pose Leaflet's `fitBounds` picks for its target variant is rendered at the same pose Leaflet's `fitBounds`
container size, so the static→live cross-fade aligns picks for its target container size, so the static→live
pixel-perfectly. The image fades out once Leaflet's first cross-fade aligns pixel-perfectly. The image fades out once
tile batch loads. --> Leaflet's first tile batch loads. -->
<img <img
class="hero-static hero-static-light hero-static-wide" class="hero-static hero-static-light hero-static-wide"
class:faded={heroMapReady} class:faded={heroMapReady}
@@ -313,6 +327,28 @@
decoding="async" decoding="async"
/> />
{/if} {/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} {#if hike.heroMapUrlLightNarrow}
<img <img
class="hero-static hero-static-light hero-static-narrow" class="hero-static hero-static-light hero-static-narrow"
@@ -573,28 +609,37 @@
pointer-events: none; pointer-events: none;
} }
/* 2×2 picker: theme (light/dark) × viewport (wide/narrow). Each `<img>` /* 2×3 picker: theme (light/dark) × viewport (wide ≥900 / medium
* has both qualifiers (e.g. `.hero-static-light.hero-static-wide`); we * 561899 / narrow ≤560). Each `<img>` carries both qualifiers (e.g.
* hide everything by default and reveal exactly one based on the * `.hero-static-light.hero-static-wide`); we hide everything by
* active theme and the `max-width: 560px` media query. The narrow * default and reveal exactly one based on the active theme and the
* variant uses a phone-sized pose so the auto-fit zoom matches what * viewport media queries. Each variant is rendered at a fit matching
* Leaflet picks at the same container width. */ * its band so Leaflet picks the same integer zoom on first paint. */
.hero-static { display: none; } .hero-static { display: none; }
/* Default (light theme assumed, no `data-theme` attribute, no /* Default (light theme assumed): show the wide-light, then step down
* `prefers-color-scheme: dark`): show the wide-light variant. */ * the cascade as viewports shrink. */
.hero-static-light.hero-static-wide { display: block; } .hero-static-light.hero-static-wide { display: block; }
@media (max-width: 560px) { @media (max-width: 899px) {
.hero-static-light.hero-static-wide { display: none; } .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; } .hero-static-light.hero-static-narrow { display: block; }
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.hero-static-light.hero-static-wide, .hero-static-light.hero-static-wide,
.hero-static-light.hero-static-medium,
.hero-static-light.hero-static-narrow { display: none; } .hero-static-light.hero-static-narrow { display: none; }
.hero-static-dark.hero-static-wide { display: block; } .hero-static-dark.hero-static-wide { display: block; }
@media (max-width: 560px) { @media (max-width: 899px) {
.hero-static-dark.hero-static-wide { display: none; } .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; } .hero-static-dark.hero-static-narrow { display: block; }
} }
} }
@@ -602,15 +647,23 @@
/* Explicit `data-theme` always wins. */ /* Explicit `data-theme` always wins. */
:global(:root[data-theme='light']) .hero-static-dark { display: none !important; } :global(:root[data-theme='light']) .hero-static-dark { display: none !important; }
:global(:root[data-theme='light']) .hero-static-light.hero-static-wide { display: block; } :global(:root[data-theme='light']) .hero-static-light.hero-static-wide { display: block; }
@media (max-width: 560px) { @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-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='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-light { display: none !important; }
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-wide { display: block; } :global(:root[data-theme='dark']) .hero-static-dark.hero-static-wide { display: block; }
@media (max-width: 560px) { @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-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; } :global(:root[data-theme='dark']) .hero-static-dark.hero-static-narrow { display: block; }
} }
+15
View File
@@ -129,6 +129,15 @@ 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];
/** Medium-viewport variant (561899 CSS px tablets, split panes,
* small laptops). Pose picked for a tablet-sized container so the
* staticLeaflet handover lands at the same integer zoom without the
* "too zoomed in" mismatch the wide pose would produce at this width. */
heroMapUrlLightMedium?: string;
heroMapUrlDarkMedium?: string;
heroMapZoomMedium?: number;
heroMapCenterMedium?: [number, number];
/** Narrow-viewport variant of the pre-rendered hero ( 560 CSS px). /** Narrow-viewport variant of the pre-rendered hero ( 560 CSS px).
* Rendered with a phone-sized `fitWidth`/`fitHeight`, so the chosen * Rendered with a phone-sized `fitWidth`/`fitHeight`, so the chosen
* integer zoom matches what Leaflet's `fitBounds` picks at the same * integer zoom matches what Leaflet's `fitBounds` picks at the same
@@ -162,6 +171,12 @@ export type HikesOverview = {
zoom: number; zoom: number;
/** Centre `[lat, lng]` the wide static was rendered around. */ /** Centre `[lat, lng]` the wide static was rendered around. */
center: [number, number]; center: [number, number];
/** Medium-viewport variant (561899 CSS px). Pose picked for a tablet-
* sized container so the staticLeaflet handover doesn't shift the map
* at this width. */
urlMedium?: string;
zoomMedium?: number;
centerMedium?: [number, number];
/** Narrow-viewport variant for phones ( 560 CSS px). Rendered at a /** Narrow-viewport variant for phones ( 560 CSS px). Rendered at a
* phone-sized `fitWidth`/`fitHeight`, so the chosen zoom matches what * phone-sized `fitWidth`/`fitHeight`, so the chosen zoom matches what
* Leaflet picks at the same container size. The page shows it instead * Leaflet picks at the same container size. The page shows it instead