From 8a67f5fba8b4f5d875f1fec3ba01eac00f057f02 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Tue, 26 May 2026 11:51:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(hikes):=20medium=20hero=20variant=20+=20Sw?= =?UTF-8?q?itzerland-framed=20overview,=20drop=20static=E2=86=92live=20wob?= =?UTF-8?q?ble?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 2 +- scripts/build-hikes.ts | 116 +++++++++-------- scripts/staticHikeMap.ts | 2 +- src/lib/components/hikes/HikeMap.svelte | 53 ++------ .../components/hikes/HikesOverviewMap.svelte | 36 ++---- src/routes/hikes/+page.svelte | 69 ++++++++--- src/routes/hikes/[slug]/+page.svelte | 117 +++++++++++++----- src/types/hikes.ts | 15 +++ 8 files changed, 237 insertions(+), 173 deletions(-) diff --git a/package.json b/package.json index 18cd59e7..91d74371 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.90.0", + "version": "1.91.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/build-hikes.ts b/scripts/build-hikes.ts index a8b6052f..d3718656 100644 --- a/scripts/build-hikes.ts +++ b/scripts/build-hikes.ts @@ -29,7 +29,7 @@ import { type GpxStage } from '../src/lib/server/gpx.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 { computeElevationStats, computeElevationRange } from '../src/lib/hikes/elevation.js'; import type { @@ -692,7 +692,17 @@ const HERO_NARROW_HEIGHT = 1200; const HERO_NARROW_FIT_WIDTH = 400; const HERO_NARROW_FIT_HEIGHT = 480; -type HeroVariant = 'wide' | 'narrow'; +// Medium-viewport variant for the 561–899 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<{ name: HeroVariant; @@ -708,6 +718,13 @@ const HERO_VARIANT_SPECS: ReadonlyArray<{ fitWidth: HERO_FIT_WIDTH, 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', width: HERO_NARROW_WIDTH, @@ -717,32 +734,23 @@ const HERO_VARIANT_SPECS: ReadonlyArray<{ } ]; -// Padding + max-zoom match the live overview map's -// `fitBounds(..., { padding: [32, 32], maxZoom: 13 })` so the static lands -// at the same pose Leaflet will fit to. fitHeight matches the page's -// `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; +// Bump when the overview renderer's output changes — e.g. stroke widths, +// palette tweaks, framing constants in `processOverview`. +const OVERVIEW_RENDER_VERSION = 2; 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`. +// One spec per viewport band; the overview's per-variant zoom is fixed in +// `processOverview` rather than being derived from a fit bbox, so we only +// need to know the output canvas size here. 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 } + { name: 'wide', width: HERO_WIDTH, height: HERO_HEIGHT }, + { name: 'medium', width: HERO_MEDIUM_WIDTH, height: HERO_MEDIUM_HEIGHT }, + { name: 'narrow', width: HERO_NARROW_WIDTH, height: HERO_NARROW_HEIGHT } ]; type OverviewVariantResult = { @@ -764,49 +772,47 @@ async function processOverview( })); if (lines.length === 0) return undefined; - // Union bbox over every hike's bbox — that's what Leaflet's - // `fitBounds(bounds)` operates on with `extend()` per polyline. Using - // each hike's bbox rather than every polyline point keeps the math - // cheap without losing the framing accuracy. - let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const h of hikes) { - const [a, b, c, d] = h.bbox; - if (a < minLat) minLat = a; - if (c > maxLat) maxLat = c; - if (b < minLng) minLng = b; - if (d > maxLng) maxLng = d; - } - if (!Number.isFinite(minLat)) return undefined; - const bbox: [number, number, number, number] = [minLat, minLng, maxLat, maxLng]; + // Frame on Switzerland, not the union of hike bboxes — the overview + // reads as "hikes across CH" instead of zooming in on whichever corner + // the catalogue clusters in. Center is the country's approximate + // geographic midpoint; zoom is picked per-variant so CH fills the + // hero at each viewport without the country bbox having to fit + // pixel-perfectly inside `OVERVIEW_FIT_*` (which would force the wide + // variant down to z=7 — too far out). At these zooms the rendered + // canvas slightly overflows the visible hero on the short axis, and + // `object-fit: none` crops to the centre — exactly what we want for + // a "frame on the country" composition. + const CH_CENTER: [number, number] = [46.82, 8.23]; + const OVERVIEW_ZOOM_BY_VARIANT: Record = { + wide: 8, + medium: 8, + narrow: 7 + }; const slug = '_overview'; const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images'); await fs.mkdir(outDir, { recursive: true }); 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 (!pose) return undefined; + const zoom = OVERVIEW_ZOOM_BY_VARIANT[spec.name]; + const c = lngLatToPx(CH_CENTER[1], CH_CENTER[0], zoom); + const pose = { + zoom, + centerLat: CH_CENTER[0], + centerLng: CH_CENTER[1], + originX: Math.round(c.x - spec.width / 2), + originY: Math.round(c.y - spec.height / 2) + }; const hash = crypto .createHash('sha256') .update( JSON.stringify({ - bbox, + center: CH_CENTER, + zoom, 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 }) ) @@ -877,6 +883,9 @@ async function processOverview( url: byVariant.wide.url, zoom: byVariant.wide.zoom, center: byVariant.wide.center, + urlMedium: byVariant.medium?.url, + zoomMedium: byVariant.medium?.zoom, + centerMedium: byVariant.medium?.center, urlNarrow: byVariant.narrow?.url, zoomNarrow: byVariant.narrow?.zoom, centerNarrow: byVariant.narrow?.center @@ -1388,11 +1397,16 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise { - // Initial tiles at the static-hero pose are present. If we - // 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); - }); + onReady?.(); }); // Canvas-rendered polylines can't resolve CSS custom properties, diff --git a/src/lib/components/hikes/HikesOverviewMap.svelte b/src/lib/components/hikes/HikesOverviewMap.svelte index b09d2e11..f35c5873 100644 --- a/src/lib/components/hikes/HikesOverviewMap.svelte +++ b/src/lib/components/hikes/HikesOverviewMap.svelte @@ -151,35 +151,17 @@ // union bounds. let initialBounds: ReturnType | null = null; - // First-paint handover: when the schematic tile layer finishes - // loading its initial batch, fire `onReady` (so the static hero - // can fade out) and — if we opened with `setView` to match a - // pre-rendered hero — animate to Leaflet's natural `fitBounds` - // of the union polyline bounds. The fade overlaps with the zoom - // animation so the user sees the map ease into its final - // framing as the static dissolves. Mirrors the same pattern in + // First-paint handover: fire `onReady` once the schematic tile + // layer's initial batch loads so the static hero can fade out. + // The map already opened at the static pose via setView (see + // the initialCenter branch below), so no extra animation is + // needed — and `flyToBounds(union)` here used to cause a + // visible wobble on hikes whose union bbox sits at an integer- + // zoom boundary, where the static's fit and Leaflet's runtime + // fit disagree by one zoom step. Mirrors the same fix in // `HikeMap.svelte`. tileLayers.schematic.once('load', () => { - if (!initialCenter || typeof initialZoom !== 'number' || !initialBounds) { - 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); - }); + onReady?.(); }); // One polyline per hike, sourced from the manifest's already- diff --git a/src/routes/hikes/+page.svelte b/src/routes/hikes/+page.svelte index c85d7a14..cb1c31db 100644 --- a/src/routes/hikes/+page.svelte +++ b/src/routes/hikes/+page.svelte @@ -28,22 +28,30 @@ // page's hero map. 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 `` 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. + // Three-band viewport switch — drives which pre-rendered pose + // (`HIKES_OVERVIEW.zoom/center` vs. `.zoomMedium/.centerMedium` vs. + // `.zoomNarrow/.centerNarrow`) we hand to Leaflet's first `setView` so + // it lands aligned with whichever static `` the CSS is showing. + // Starts `false`/`false` to match SSR (which has no window); the + // $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 mediumViewport = $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; + const mqNarrow = window.matchMedia('(max-width: 560px)'); + const mqMedium = window.matchMedia('(min-width: 561px) and (max-width: 899px)'); + narrowViewport = mqNarrow.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(() => { @@ -51,6 +59,9 @@ if (narrowViewport && HIKES_OVERVIEW.urlNarrow && HIKES_OVERVIEW.centerNarrow && typeof HIKES_OVERVIEW.zoomNarrow === 'number') { 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 }; }); @@ -193,9 +204,9 @@ 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. 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 + once Leaflet's first tile batch loads. Three width variants + ship — wide (≥900 CSS px), medium (561–899), narrow (≤560). + CSS chooses which one shows based on a media query so hydration doesn't need to wait. --> + {#if HIKES_OVERVIEW.urlMedium} + + {/if} {#if HIKES_OVERVIEW.urlNarrow} ` 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. + // Three-band viewport switch (narrow ≤560, medium 561–899, wide ≥900) + // — 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`/`false` for SSR; the $effect snaps to real values on + // mount and keeps both flags in sync across rotate/resize. `narrow` + // wins over `medium` when both would match. See the matching overview + // pattern in `/hikes/+page.svelte`. let narrowViewport = $state(false); + let mediumViewport = $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; + const mqNarrow = window.matchMedia('(max-width: 560px)'); + const mqMedium = window.matchMedia('(min-width: 561px) and (max-width: 899px)'); + narrowViewport = mqNarrow.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)); @@ -84,6 +91,13 @@ ) { 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') { return { center: hike.heroMapCenter, zoom: hike.heroMapZoom }; } @@ -284,14 +298,14 @@
{#if hike.heroMapUrlLight} + polyline + public photo markers. Six variants ship — theme + (light/dark) × viewport (wide ≥900 / medium 561–899 / + narrow ≤560 CSS px). Theme is picked by `data-theme` / + `prefers-color-scheme`; viewport by media queries. 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.heroMapUrlLightMedium} + + {/if} + {#if hike.heroMapUrlDarkMedium} + + {/if} {#if hike.heroMapUrlLightNarrow} ` - * 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. */ + /* 2×3 picker: theme (light/dark) × viewport (wide ≥900 / medium + * 561–899 / narrow ≤560). Each `` carries 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 + * viewport media queries. Each variant is rendered at a fit matching + * its band so Leaflet picks the same integer zoom on first paint. */ .hero-static { display: none; } - /* Default (light theme assumed, no `data-theme` attribute, no - * `prefers-color-scheme: dark`): show the wide-light variant. */ + /* Default (light theme assumed): show the wide-light, then step down + * the cascade as viewports shrink. */ .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-medium { display: block; } + } + @media (max-width: 560px) { + .hero-static-light.hero-static-medium { display: none; } .hero-static-light.hero-static-narrow { display: block; } } @media (prefers-color-scheme: dark) { .hero-static-light.hero-static-wide, + .hero-static-light.hero-static-medium, .hero-static-light.hero-static-narrow { display: none; } .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-medium { display: block; } + } + @media (max-width: 560px) { + .hero-static-dark.hero-static-medium { display: none; } .hero-static-dark.hero-static-narrow { display: block; } } } @@ -602,15 +647,23 @@ /* 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) { + @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-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='dark']) .hero-static-light { display: none !important; } :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-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; } } diff --git a/src/types/hikes.ts b/src/types/hikes.ts index 1d98df10..115ce91e 100644 --- a/src/types/hikes.ts +++ b/src/types/hikes.ts @@ -129,6 +129,15 @@ export type HikeManifestEntry = { /** Map centre `[lat, lng]` the static hero was rendered around. */ heroMapCenter?: [number, number]; + /** Medium-viewport variant (561–899 CSS px — tablets, split panes, + * small laptops). Pose picked for a tablet-sized container so the + * static→Leaflet 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). * Rendered with a phone-sized `fitWidth`/`fitHeight`, so the chosen * integer zoom matches what Leaflet's `fitBounds` picks at the same @@ -162,6 +171,12 @@ export type HikesOverview = { zoom: number; /** Centre `[lat, lng]` the wide static was rendered around. */ center: [number, number]; + /** Medium-viewport variant (561–899 CSS px). Pose picked for a tablet- + * sized container so the static→Leaflet 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 * phone-sized `fitWidth`/`fitHeight`, so the chosen zoom matches what * Leaflet picks at the same container size. The page shows it instead