From fe08e06a029359ecf5e7b61fdd39db4ea3dbb010 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Tue, 19 May 2026 08:18:23 +0200 Subject: [PATCH] feat(hikes): pre-rendered overview hero map with same handover pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the per-hike detail-page hero on the /hikes index. Build emits one WebP at the union bbox of every visible hike with each preview polyline drawn in its SAC-tier colour; page renders it under the live Leaflet map and fades it out once the first tile batch loads. Tile fetcher now distinguishes HTTP 4xx ("intentionally blank — outside Switzerland") from real network errors, so the larger overview canvas that extends into DE/IT/FR doesn't trip the network-failure abort. --- package.json | 2 +- scripts/build-hikes.ts | 140 ++++++++++++++- scripts/staticHikeMap.ts | 166 +++++++++++++++--- .../components/hikes/HikesOverviewMap.svelte | 72 +++++++- src/routes/hikes/+page.svelte | 71 +++++++- src/types/hikes.ts | 14 ++ 6 files changed, 427 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index fce05071..2cc4583a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.72.0", + "version": "1.73.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/build-hikes.ts b/scripts/build-hikes.ts index c61270c3..a898d2e9 100644 --- a/scripts/build-hikes.ts +++ b/scripts/build-hikes.ts @@ -27,10 +27,11 @@ import { type GpxPoint } from '../src/lib/server/gpx.js'; import { simplifyTrack } from '../src/lib/server/simplifyTrack.js'; -import { computeStaticMapPose, renderStaticMap } from './staticHikeMap.js'; +import { computeStaticMapPose, renderOverviewMap, renderStaticMap } from './staticHikeMap.js'; import type { Difficulty, HikeManifestEntry, + HikesOverview, ImagePoint, ImageVariant } from '../src/types/hikes.js'; @@ -586,6 +587,133 @@ const HERO_BADGE_ICON_DARK = '#2e3440'; // existing files get re-rendered on the next build. const HERO_RENDER_VERSION = 5; +// 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. +const OVERVIEW_SAC_COLOR: Record = { + T1: '#f5a623', + T2: '#dc1d2a', + T3: '#dc1d2a', + T4: '#2965c8', + T5: '#2965c8', + T6: '#2965c8' +}; + +// 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; + +async function processOverview( + hikes: HikeManifestEntry[] +): Promise { + const lines = hikes + .filter((h) => h.previewPolyline && h.previewPolyline.length >= 2) + .map((h) => ({ + points: h.previewPolyline, + color: OVERVIEW_SAC_COLOR[h.difficulty] ?? '#5e81ac' + })); + 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]; + + 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 + }); + if (!ok) { + console.warn(`[build-hikes:_overview] render failed — too few tiles fetched`); + return undefined; + } + console.log(`[build-hikes:_overview] rendered ${outName} in ${Date.now() - renderT0}ms`); + } else { + console.log(`[build-hikes:_overview] cached (${outName})`); + } + + // Sweep orphan overview heroes from previous builds. + try { + const existing = await fs.readdir(outDir); + const orphans = existing.filter((f) => f !== outName); + 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)`); + } + } catch { + // dir didn't exist before this run + } + + return { + url: `/hikes/${slug}/images/${outName}`, + zoom: pose.zoom, + center: [pose.centerLat, pose.centerLng] + }; +} + async function processHero( slug: string, track: GpxPoint[], @@ -990,11 +1118,17 @@ async function main() { hikes.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); + // Build the overview hero from the listing-visible set (matches what + // `/hikes` shows: hidden hikes are filtered out by the page loader). + const overview = await processOverview(hikes.filter((h) => !h.hidden)); + await fs.mkdir(path.dirname(MANIFEST_OUT), { recursive: true }); const banner = '// AUTO-GENERATED by scripts/build-hikes.ts — do not edit by hand.\n' + - "import type { HikeManifestEntry } from '$types/hikes';\n\n"; - const body = `export const HIKES: HikeManifestEntry[] = ${JSON.stringify(hikes, null, 2)} as const;\n`; + "import type { HikeManifestEntry, HikesOverview } from '$types/hikes';\n\n"; + const body = + `export const HIKES: HikeManifestEntry[] = ${JSON.stringify(hikes, null, 2)} as const;\n\n` + + `export const HIKES_OVERVIEW: HikesOverview | null = ${JSON.stringify(overview ?? null, null, 2)};\n`; const manifestSrc = banner + body; await fs.writeFile(MANIFEST_OUT, manifestSrc); diff --git a/scripts/staticHikeMap.ts b/scripts/staticHikeMap.ts index 73be6cdc..bf8962f5 100644 --- a/scripts/staticHikeMap.ts +++ b/scripts/staticHikeMap.ts @@ -48,12 +48,19 @@ async function pathExists(p: string): Promise { } } +/** `null` = network failure (we'll count it against the abort threshold). + * `'blank'` = HTTP 4xx, i.e. the tile is intentionally not served — for + * the Swisstopo Pixelkarte that means we're outside Switzerland's bbox. + * The overview hero canvas extends into DE/IT/FR, so we treat blanks as + * "OK, just nothing there" rather than failures. */ +type TileResult = Buffer | 'blank' | null; + async function fetchTile( layer: string, z: number, x: number, y: number -): Promise { +): Promise { const key = `${layer.replace(/[^a-z0-9]/gi, '_')}_${z}_${x}_${y}.jpeg`; const cachePath = path.join(TILE_CACHE_DIR, key); try { @@ -65,12 +72,23 @@ async function fetchTile( const res = await fetch(tileUrl(sub, layer, z, x, y), { headers: { 'User-Agent': USER_AGENT } }); - if (!res.ok) return null; + if (!res.ok) { + // 4xx means "we don't serve this tile" (out-of-bounds for the + // Swiss data set). Anything else (5xx) is a real failure. + if (res.status >= 400 && res.status < 500) return 'blank'; + if (process.env.STATIC_MAP_DEBUG) { + console.warn(`[staticHikeMap] tile ${z}/${x}/${y} HTTP ${res.status}`); + } + return null; + } const buf = Buffer.from(await res.arrayBuffer()); await fs.mkdir(TILE_CACHE_DIR, { recursive: true }); await fs.writeFile(cachePath, buf); return buf; - } catch { + } catch (err) { + if (process.env.STATIC_MAP_DEBUG) { + console.warn(`[staticHikeMap] tile ${z}/${x}/${y} error:`, err); + } return null; } } @@ -111,6 +129,10 @@ export interface ComputeStaticMapPoseOpts { * smaller ones. */ fitWidth?: number; fitHeight?: number; + /** Upper bound on the zoom search — mirrors Leaflet's `fitBounds({ maxZoom })`. + * Use this when the live map clamps its zoom so the static hero doesn't + * land at a more detailed level than Leaflet will ever show. */ + maxZoom?: number; } /** Pure-math pass: pick the zoom + centre + canvas origin that the static @@ -122,6 +144,7 @@ export function computeStaticMapPose(opts: ComputeStaticMapPoseOpts): StaticMapP const paddingPx = opts.paddingPx ?? 24; const fitWidth = opts.fitWidth ?? width; const fitHeight = opts.fitHeight ?? height; + const maxZoom = opts.maxZoom ?? 18; const [minLat, minLng, maxLat, maxLng] = opts.bbox; if ( @@ -139,7 +162,7 @@ export function computeStaticMapPose(opts: ComputeStaticMapPoseOpts): StaticMapP // integer-zoom search, so a viewport matching `fitWidth × fitHeight` // will choose the same zoom Leaflet does for the same bbox. let zoom = 7; - for (let z = 18; z >= 7; z--) { + for (let z = maxZoom; z >= 7; z--) { const tl = lngLatToPx(minLng, maxLat, z); const br = lngLatToPx(maxLng, minLat, z); if (br.x - tl.x <= innerW && br.y - tl.y <= innerH) { @@ -189,24 +212,28 @@ export interface RenderStaticMapOpts { photoMarkerIconColor?: string; } -/** Render and write a single static hero map at the given pose. Returns - * `false` on failure (zero tiles fetched, degenerate inputs). */ -export async function renderStaticMap(opts: RenderStaticMapOpts): Promise { - const width = opts.width ?? 1600; - const height = opts.height ?? 1000; - const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe'; - const { zoom, originX, originY } = opts.pose; +/** Fetch every Swisstopo tile covering the canvas at the given pose, then + * composite them into a single PNG buffer. Returns `null` when fewer than + * half the tiles arrive (a patchy hero is worse than no hero). Shared by + * `renderStaticMap` (per-hike hero) and `renderOverviewMap` (the /hikes + * landing-page hero) so both pull the same tile cache and use the same + * fallback colour. */ +async function composeBaseMap( + pose: StaticMapPose, + width: number, + height: number, + layer: string +): Promise { + const { zoom, originX, originY } = pose; - if (opts.polyline.length < 2) return false; - - // Tiles covering [originX, originX+width) × [originY, originY+height). const minTileX = Math.floor(originX / TILE_SIZE); const maxTileX = Math.floor((originX + width - 1) / TILE_SIZE); const minTileY = Math.floor(originY / TILE_SIZE); const maxTileY = Math.floor((originY + height - 1) / TILE_SIZE); // Parallel tile fetches — disk cache makes follow-up builds essentially - // free, but the first build pulls ~6–20 tiles per hike. + // free, but the first build pulls ~6–20 tiles per per-hike hero and + // considerably more for the overview hero. const tileJobs: Array<{ tx: number; ty: number; left: number; top: number }> = []; for (let ty = minTileY; ty <= maxTileY; ty++) { for (let tx = minTileX; tx <= maxTileX; tx++) { @@ -226,29 +253,46 @@ export async function renderStaticMap(opts: RenderStaticMapOpts): Promise = []; - let fetched = 0; + let networkFailures = 0; for (const { job, buf } of tileBufs) { - if (!buf) continue; - fetched++; + if (buf === null) { + networkFailures++; + continue; + } + if (buf === 'blank') continue; // out-of-bounds, draw the fallback grey composites.push({ input: buf, left: job.left, top: job.top }); } - // Abandon when fewer than half the tiles arrived — the result would - // be too patchy to ship and we'd rather show no static map than a - // confusing one. - if (fetched < tileJobs.length / 2) return false; + // Network-failure threshold (not "fewer than half present"): blank + // out-of-bounds tiles are an expected outcome for the overview hero + // that extends past Switzerland's edges, so they don't count against + // the abort threshold. + if (networkFailures > tileJobs.length / 2) return null; - // Step 1: build the bare map tile composite. Tile composite is identical - // regardless of UI theme — we deliberately don't invert the Pixelkarte - // for dark mode (its colour palette doesn't survive a naive invert). - // Only the SVG overlay below changes per theme. - const mapBuf = await sharp({ + // Tile composite is identical regardless of UI theme — we deliberately + // don't invert the Pixelkarte for dark mode (its colour palette doesn't + // survive a naive invert). Only the SVG overlay above changes per theme. + return sharp({ create: { width, height, channels: 3, background: { r: 235, g: 235, b: 235 } } }) .composite(composites) .png() .toBuffer(); +} - // Step 2: SVG overlay — polyline + photo markers + start/end dots. +/** Render and write a single static hero map at the given pose. Returns + * `false` on failure (zero tiles fetched, degenerate inputs). */ +export async function renderStaticMap(opts: RenderStaticMapOpts): Promise { + const width = opts.width ?? 1600; + const height = opts.height ?? 1000; + const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe'; + const { zoom, originX, originY } = opts.pose; + + if (opts.polyline.length < 2) return false; + + const mapBuf = await composeBaseMap(opts.pose, width, height, layer); + if (!mapBuf) return false; + + // SVG overlay — polyline + photo markers + start/end dots. const pathParts: string[] = []; for (let i = 0; i < opts.polyline.length; i++) { const [lat, lng] = opts.polyline[i]; @@ -306,3 +350,69 @@ export async function renderStaticMap(opts: RenderStaticMapOpts): Promise; + color: string; +} + +export interface RenderOverviewMapOpts { + pose: StaticMapPose; + polylines: RenderOverviewPolyline[]; + outputPath: string; + width?: number; + height?: number; + layer?: string; +} + +export async function renderOverviewMap(opts: RenderOverviewMapOpts): Promise { + const width = opts.width ?? 1600; + const height = opts.height ?? 1000; + const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe'; + const { zoom, originX, originY } = opts.pose; + + const drawable = opts.polylines.filter((p) => p.points.length >= 2); + if (drawable.length === 0) return false; + + const mapBuf = await composeBaseMap(opts.pose, width, height, layer); + if (!mapBuf) return false; + + // One per hike polyline. The overview map is rendered fairly + // zoomed-out, so even ≤150-point preview polylines stay compact. + const paths = drawable + .map((line) => { + const parts: string[] = []; + for (let i = 0; i < line.points.length; i++) { + const [lat, lng] = line.points[i]; + const p = lngLatToPx(lng, lat, zoom); + const px = p.x - originX; + const py = p.y - originY; + parts.push((i === 0 ? 'M' : 'L') + escapeSvgNumber(px) + ',' + escapeSvgNumber(py)); + } + return ( + `` + ); + }) + .join(''); + + const overlay = Buffer.from( + `` + + paths + + `` + ); + + await sharp(mapBuf) + .composite([{ input: overlay, left: 0, top: 0 }]) + .webp({ quality: 78 }) + .toFile(opts.outputPath); + + return true; +} diff --git a/src/lib/components/hikes/HikesOverviewMap.svelte b/src/lib/components/hikes/HikesOverviewMap.svelte index b06c8836..336f69cd 100644 --- a/src/lib/components/hikes/HikesOverviewMap.svelte +++ b/src/lib/components/hikes/HikesOverviewMap.svelte @@ -13,9 +13,19 @@ interface Props { hikes: HikeManifestEntry[]; + /** Initial map centre `[lat, lng]`. When provided alongside + * `initialZoom`, the map opens with `setView(center, zoom)` instead + * of `fitBounds(union)` — used by the index page to align Leaflet's + * first paint with the SSR-rendered static overview hero. */ + initialCenter?: [number, number]; + initialZoom?: number; + /** Fires once the schematic tile layer's first batch of tiles has + * finished loading — i.e. the map is visually complete. The page + * uses this to fade out the SSR-rendered static hero. */ + onReady?: () => void; } - const { hikes }: Props = $props(); + const { hikes, initialCenter, initialZoom, onReady }: Props = $props(); // Per-tier polyline colour, matching the painted-marker scheme on the // SAC badges. Canvas-rendered polylines can't resolve CSS variables, @@ -103,7 +113,17 @@ attributionControl: true, zoomControl: true, preferCanvas: true - }).setView([46.8, 8.3], 8); + }); + // Sensible default centre (mid-Switzerland) while the polyline + // layer is built up; `fitBounds` below overrides it once the + // union bounds are known. If the caller passed a pre-rendered + // hero pose, use that instead so Leaflet lands aligned with the + // static image on first paint. + if (initialCenter && typeof initialZoom === 'number') { + map.setView(initialCenter, initialZoom, { animate: false }); + } else { + map.setView([46.8, 8.3], 8); + } const tileLayers: Record> = { schematic: L.tileLayer(SWISSTOPO_FARBE, { @@ -128,8 +148,44 @@ tileLayers.schematic.addTo(map); let currentBase: BaseLayer = 'schematic'; + // Forward-declared so the tile-load handover handler below can + // close over it; populated once the polyline loop has built the + // 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 + // `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); + }); + }); + // One polyline per hike, sourced from the manifest's already- - // simplified previewPolyline (≤30 points each). + // simplified previewPolyline (≤150 points each). const layer = L.layerGroup().addTo(map); const bounds = L.latLngBounds([]); for (const hike of hikes) { @@ -164,10 +220,16 @@ } } - let initialBounds: ReturnType | null = null; if (bounds.isValid()) { - map.fitBounds(bounds, { padding: [32, 32], maxZoom: 13 }); initialBounds = bounds; + // When the caller handed us a pre-rendered hero pose, we + // already called `setView(initialCenter, initialZoom)` above + // and rely on the tile-load handler to fly to bounds (so the + // static→live cross-fade happens at the matching pose). With + // no pre-rendered hero, fitBounds straight away. + if (!initialCenter || typeof initialZoom !== 'number') { + map.fitBounds(initialBounds, { padding: [32, 32], maxZoom: 13 }); + } recenterMap = () => { if (!initialBounds) return; map.flyToBounds(initialBounds, { diff --git a/src/routes/hikes/+page.svelte b/src/routes/hikes/+page.svelte index 4918ec2b..fe4382af 100644 --- a/src/routes/hikes/+page.svelte +++ b/src/routes/hikes/+page.svelte @@ -4,11 +4,17 @@ import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.svelte'; import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte'; import Seo from '$lib/components/Seo.svelte'; + import { HIKES_OVERVIEW } from '$lib/data/hikes.generated'; import type { Difficulty } from '$types/hikes'; import type { PageProps } from './$types'; const { data }: PageProps = $props(); + // Fades the SSR-rendered static overview hero out once Leaflet's first + // schematic-tile batch has loaded. Same handover pattern as the detail + // page's hero map. + let heroMapReady = $state(false); + // 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 — @@ -80,7 +86,31 @@
- + {#if HIKES_OVERVIEW} + + + {/if} + (heroMapReady = true)} + />
@@ -130,11 +160,50 @@ position: relative; isolation: isolate; width: 100vw; + /* Reserve the eventual map height up-front so the static image and + * Leaflet's tile pane sit on a stable surface (no scroll-shift when + * either mounts). Same clamp as `:global(.overview-map)` inside + * the HikesOverviewMap component. */ + min-height: clamp(320px, 50vh, 520px); margin-left: calc(50% - 50vw); margin-right: calc(50% - 50vw); margin-top: calc(-1 * (3rem + max(12px, env(safe-area-inset-top, 0px) + 4px))); margin-bottom: 0; overflow: hidden; + /* Transparent so the page background shows through any tile gap + * during the static→live cross-fade rather than Leaflet's grey + * default. */ + background: transparent; + } + + /* Pre-rendered overview hero. Native pixel size + centred so it matches + * Leaflet's tile rendering 1:1; `cover` would scale and break alignment + * during the cross-fade. Wider viewports just reveal more of the + * 3840×2400 canvas; the union bbox (where the trails live) is always + * pixel-aligned with the live map. */ + .hero-static { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: none; + object-position: center; + z-index: 1; + opacity: 1; + transition: opacity 450ms ease; + pointer-events: none; + } + + .hero-static.faded { + opacity: 0; + } + + /* 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) { + position: relative; + z-index: 2; + background: transparent; } /* Push Leaflet's top-left controls below the sticky nav. */ diff --git a/src/types/hikes.ts b/src/types/hikes.ts index 65cd2725..f9ba7d81 100644 --- a/src/types/hikes.ts +++ b/src/types/hikes.ts @@ -94,3 +94,17 @@ export type HikeManifestEntry = { // Geo-tagged photos shown as map markers on the detail page: imagePoints: ImagePoint[]; }; + +/** Pre-rendered hero map for the `/hikes` index page. One image covers + * every listed hike's `previewPolyline`, coloured by SAC tier. The page + * 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. */ + url: string; + /** Integer zoom the static was rendered at (matches Leaflet's + * `fitBounds(unionBounds, { padding: 32, maxZoom: 13 })` choice). */ + zoom: number; + /** Centre `[lat, lng]` the static was rendered around. */ + center: [number, number]; +};