diff --git a/package.json b/package.json index 2cc4583a..ad742d30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.73.0", + "version": "1.74.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/build-hikes.ts b/scripts/build-hikes.ts index a898d2e9..4374facc 100644 --- a/scripts/build-hikes.ts +++ b/scripts/build-hikes.ts @@ -587,6 +587,46 @@ const HERO_BADGE_ICON_DARK = '#2e3440'; // existing files get re-rendered on the next build. 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 // the `SAC_COLOR` map in `HikesOverviewMap.svelte` so the static hero's // 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. 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 = [ + { 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( hikes: HikeManifestEntry[] ): Promise { @@ -637,68 +700,92 @@ async function processOverview( if (!Number.isFinite(minLat)) return undefined; 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 outName = `overview.${hash}.webp`; const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images'); await fs.mkdir(outDir, { recursive: true }); - const outPath = path.join(outDir, outName); - const renderT0 = Date.now(); - console.log( - `[build-hikes:_overview] ${lines.length} polylines · zoom ${pose.zoom} · ` + - `${Math.round(HERO_WIDTH / 256)}×${Math.round(HERO_HEIGHT / 256)} tile grid` - ); - if (!(await pathExists(outPath))) { - const ok = await renderOverviewMap({ - pose, - polylines: lines, - outputPath: outPath, - width: HERO_WIDTH, - height: HERO_HEIGHT + async function renderVariant(spec: OverviewVariantSpec): Promise { + const pose = computeStaticMapPose({ + bbox, + width: spec.width, + height: spec.height, + paddingPx: OVERVIEW_PADDING_PX, + fitWidth: spec.fitWidth, + fitHeight: spec.fitHeight, + maxZoom: OVERVIEW_MAX_ZOOM }); - if (!ok) { - console.warn(`[build-hikes:_overview] render failed — too few tiles fetched`); - return undefined; + if (!pose) 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..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 { - console.log(`[build-hikes:_overview] cached (${outName})`); + + return { + 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> = {}; + 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(); + for (const r of Object.values(byVariant)) { + if (r) keep.add(r.outName); + } try { 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) { await Promise.all(orphans.map((f) => fs.unlink(path.join(outDir, f)).catch(() => {}))); console.log(`[build-hikes:_overview] removed ${orphans.length} orphaned file(s)`); @@ -708,28 +795,30 @@ async function processOverview( } return { - url: `/hikes/${slug}/images/${outName}`, - zoom: pose.zoom, - center: [pose.centerLat, pose.centerLng] + url: byVariant.wide.url, + zoom: byVariant.wide.zoom, + 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( slug: string, track: GpxPoint[], bbox: [number, number, number, number], imagePoints: ImagePoint[] -): Promise< - | { - lightUrl: string; - lightOutName: string; - darkUrl: string; - darkOutName: string; - zoom: number; - center: [number, number]; - } - | undefined -> { +): Promise> | undefined> { if (track.length < 2) return undefined; const polyline: Array<[number, number]> = track.map((p) => [p.lat, p.lng]); @@ -740,83 +829,98 @@ async function processHero( .filter((ip) => ip.visibility !== 'private') .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'); await fs.mkdir(outDir, { recursive: true }); - // Per-theme hash + render. Theme is part of the hash so light and dark - // produce distinct filenames; both variants regenerate whenever the - // route, photo set, or renderer version changes. - async function renderVariant(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: HERO_WIDTH, - h: HERO_HEIGHT, + // One pose per viewport variant — narrow uses a phone-sized fit so the + // chosen integer zoom matches what Leaflet picks at the same container + // size, eliminating the visible "the static is too zoomed in" mismatch + // the user sees with only a desktop-sized pose. + async function renderForViewport( + spec: (typeof HERO_VARIANT_SPECS)[number] + ): Promise { + const pose = computeStaticMapPose({ + bbox, + width: spec.width, + height: spec.height, + fitWidth: spec.fitWidth, + fitHeight: spec.fitHeight + }); + 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}..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, - poly: polyline, - photos: photoMarkers, - fill: fillColor, - border: borderColor, - icon: iconColor, - v: HERO_RENDER_VERSION - }) - ) - .digest('hex') - .slice(0, 8); + outputPath: outPath, + width: spec.width, + height: spec.height, + photoMarkers, + photoMarkerColor: fillColor, + photoMarkerBorderColor: borderColor, + photoMarkerIconColor: iconColor + }); + if (!ok) return undefined; + } - const outName = `hero-${theme}.${hash}.webp`; - 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 }; } - 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')]); - 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 variants = await Promise.all(HERO_VARIANT_SPECS.map(renderForViewport)); + const out: Partial> = {}; + for (let i = 0; i < HERO_VARIANT_SPECS.length; i++) { + const v = variants[i]; + if (v) out[HERO_VARIANT_SPECS[i].name] = v; + } + // At minimum we need the wide variant — that's what desktop falls back + // to, and CLS-reservation on the page expects it. Narrow is best-effort. + if (!out.wide) return undefined; + return out; } // --------------------------------------------------------------------------- @@ -985,8 +1089,11 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise` 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 // hydration pass) shows every hike. `$effect` below clamps them down // to the actual data maxes once `data.hikes` is fully populated — @@ -91,12 +117,12 @@ visible hike's preview polyline, coloured by SAC tier. Displayed at native pixel size (`object-fit: none`) so it overlays Leaflet's live tiles exactly. The image fades out - once Leaflet's first tile batch loads. Unlike the detail - hero, the overview map looks the same in light and dark - mode (only the per-hike camera badges are theme-aware, - and the overview has none) so a single variant ships. --> + once Leaflet's first tile batch loads. Two width variants + ship — desktop (wide pose) and phone (narrow pose, ≤560 CSS + px). CSS chooses which one shows based on a media query so + hydration doesn't need to wait. --> + {#if HIKES_OVERVIEW.urlNarrow} + + {/if} {/if} (heroMapReady = true)} /> @@ -198,6 +235,16 @@ 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 * shows through until Leaflet's tile pane paints over it. */ .hero-map :global(.overview-map) { diff --git a/src/routes/hikes/[slug]/+page.svelte b/src/routes/hikes/[slug]/+page.svelte index c8a9d0c9..6a0b0e0b 100644 --- a/src/routes/hikes/[slug]/+page.svelte +++ b/src/routes/hikes/[slug]/+page.svelte @@ -31,6 +31,38 @@ // handover is a soft cross-fade rather than a swap. 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 + // `` 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(() => { let aborted = false; fetch(hike.trackUrl) @@ -233,15 +265,16 @@
{#if hike.heroMapUrlLight} + polyline + public photo markers. Four variants ship — theme + (light/dark) × viewport (wide/narrow). Theme is picked by + `data-theme` / `prefers-color-scheme`; viewport by a + `max-width: 560px` media query. Each variant is rendered at + the same pose Leaflet's `fitBounds` picks for its target + container size, so the static→live cross-fade aligns + pixel-perfectly. The image fades out once Leaflet's first + tile batch loads. --> {/if} + {#if hike.heroMapUrlLightNarrow} + + {/if} + {#if hike.heroMapUrlDarkNarrow} + + {/if} {#if track && track.length > 0} (heroMapReady = true)} /> {:else if trackError}
Track konnte nicht geladen werden: {trackError}
- {:else if !hike.heroMapUrl} + {:else if !hike.heroMapUrlLight}
Track wird geladen…
{/if}
@@ -447,18 +502,46 @@ pointer-events: none; } - /* Theme-aware switch between the two pre-rendered variants. Default - * is light; `prefers-color-scheme: dark` flips it; an explicit - * `data-theme` attribute on `` always wins (higher specificity). */ - .hero-static-dark { display: none; } + /* 2×2 picker: theme (light/dark) × viewport (wide/narrow). Each `` + * has both qualifiers (e.g. `.hero-static-light.hero-static-wide`); we + * hide everything by default and reveal exactly one based on the + * active theme and the `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) { - .hero-static-light { display: none; } - .hero-static-dark { display: block; } + .hero-static-light.hero-static-wide, + .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 diff --git a/src/types/hikes.ts b/src/types/hikes.ts index f9ba7d81..0fc745b6 100644 --- a/src/types/hikes.ts +++ b/src/types/hikes.ts @@ -81,8 +81,7 @@ export type HikeManifestEntry = { * arrives. */ heroMapUrlLight?: string; /** Pre-rendered hero map (dark theme). Same pose as - * `heroMapUrlLight` but with the tile composite passed through an - * `invert(1) hue-rotate(180deg)` filter so the map fits the dark UI. */ + * `heroMapUrlLight` but with theme-appropriate photo-marker colours. */ heroMapUrlDark?: string; /** Zoom level the static hero was rendered at. Leaflet uses this with * `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. */ 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: imagePoints: ImagePoint[]; }; @@ -100,11 +110,19 @@ export type HikeManifestEntry = { * 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. */ export type HikesOverview = { - /** Absolute URL of the pre-rendered WebP. */ + /** Absolute URL of the wide (desktop) pre-rendered WebP. */ url: string; - /** Integer zoom the static was rendered at (matches Leaflet's - * `fitBounds(unionBounds, { padding: 32, maxZoom: 13 })` choice). */ + /** Integer zoom the wide static was rendered at (matches Leaflet's + * `fitBounds(unionBounds, { padding: 32, maxZoom: 13 })` choice on a + * desktop-sized container). */ zoom: number; - /** Centre `[lat, lng]` the static was rendered around. */ + /** Centre `[lat, lng]` the wide static was rendered around. */ 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]; };