From fd2d8a58d9d3ba3df8017455e2fa5407b48c0f1c Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Mon, 18 May 2026 23:38:24 +0200 Subject: [PATCH] feat(hikes): pre-rendered static hero map with smooth Leaflet handover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each hike now ships two SSR-friendly hero images (light + dark theme), composited at build time from Swisstopo tiles plus an SVG overlay of the trail polyline, start/end markers, and per-photo camera badges. The detail page renders the right variant immediately at first paint, then hands over to live Leaflet without visible jumps. Renderer (scripts/staticHikeMap.ts): - Parallel tile fetcher with on-disk cache (scripts/.cache/swisstopo- tiles/) for re-build idempotency. - `computeStaticMapPose` picks the zoom + centre Leaflet's fitBounds would land on at a reference 1920x640 viewport, so the static frames the full route on every typical desktop hero. - Canvas rendered at 3840x2400 — large enough to fully cover ultrawide / 4K displays at native pixel size, so `object-fit: none` keeps the trail pixel-aligned with Leaflet's tile pane. - SVG overlay: trail in Nord red, start dot Nord green, end dot Nord red, Lucide `camera` icon inside each photo badge. Photo badge fill / border / icon-stroke colours are passed per theme so light and dark variants match the live `.hike-photo-marker .badge` styling exactly (Nord10/Nord8 fill, Nord6/Nord1 border, white/Nord0 icon stroke). Map tiles themselves are identical across themes — no naive invert (it mangles the Pixelkarte palette). - Public photo markers only — private positions are filtered out so they don't leak in the SSR image. Build wiring (scripts/build-hikes.ts): - `processHero` renders both variants in parallel, hashes inputs per theme, skips on cache hit. Output filenames carry the content hash so changes invalidate cleanly via the existing orphan sweep. - `HikeManifestEntry` gains `heroMapUrlLight`, `heroMapUrlDark`, `heroMapZoom`, `heroMapCenter`. Detail page (src/routes/hikes/[slug]/+page.svelte): - Reserves the hero box height up front (kills CLS). - Renders both `` tags; CSS picks the right one via `data-theme` with `prefers-color-scheme` as the fallback. - `object-fit: none; object-position: center` so the image displays at native pixel size, perfectly aligned with Leaflet's tile rendering. - `isolation: isolate` on the hero gives Leaflet's z-index:200+ panes a stacking context so they can't bleed over the sticky nav. HikeMap (src/lib/components/hikes/HikeMap.svelte): - New `initialCenter` / `initialZoom` props — when set, the map opens with `setView` at the static hero's pose instead of `fitBounds`. - New `onReady` callback — fires after the post-fly-to-bounds tile batch finishes loading (or a 350 ms safety timeout), letting the detail page fade the static out onto fully-painted tiles instead of onto a brief grey gap. - Sequence: render static -> Leaflet `setView` to match -> first tile load -> `flyToBounds(track)` to the natural fit -> wait for new tiles -> fade static out. --- package.json | 2 +- scripts/build-hikes.ts | 176 +++++++++++++- scripts/staticHikeMap.ts | 308 ++++++++++++++++++++++++ src/lib/components/hikes/HikeMap.svelte | 76 +++++- src/routes/hikes/[slug]/+page.svelte | 109 ++++++++- src/types/hikes.ts | 17 ++ 6 files changed, 675 insertions(+), 13 deletions(-) create mode 100644 scripts/staticHikeMap.ts diff --git a/package.json b/package.json index 6ef666fc..fce05071 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.71.0", + "version": "1.72.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/build-hikes.ts b/scripts/build-hikes.ts index d628360f..c61270c3 100644 --- a/scripts/build-hikes.ts +++ b/scripts/build-hikes.ts @@ -27,6 +27,7 @@ import { type GpxPoint } from '../src/lib/server/gpx.js'; import { simplifyTrack } from '../src/lib/server/simplifyTrack.js'; +import { computeStaticMapPose, renderStaticMap } from './staticHikeMap.js'; import type { Difficulty, HikeManifestEntry, @@ -54,7 +55,7 @@ const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'hikes.generated.ts') const ELEV_SMOOTH_WINDOW = 5; // moving-average window for altitude denoising const ELEV_MIN_STEP_M = 3; // discard altitude deltas below this -const PREVIEW_POLYLINE_MAX_POINTS = 30; +const PREVIEW_POLYLINE_MAX_POINTS = 150; const IMAGE_WIDTHS = [480, 960, 1600] as const; const IMAGE_THUMBNAIL_WIDTH = 240; // popup thumbnail for map markers const MANIFEST_WARN_BYTES = 200_000; @@ -541,6 +542,155 @@ async function processIcon(slug: string, hikeDir: string): Promise<{ url: string return { url: `/hikes/${slug}/images/${outName}`, outName }; } +// --------------------------------------------------------------------------- +// Pre-rendered hero map (static Swisstopo composite + polyline overlay). +// See `scripts/staticHikeMap.ts` for the renderer; this helper just hashes +// inputs, picks an output filename, and skips when the file already exists. +// --------------------------------------------------------------------------- + +// Rendered well beyond any expected viewport width so the image, displayed +// with `object-fit: none`, covers ultrawide / 4K displays without falling +// back to upscale. The bigger canvas surrounds the bbox with extra map +// context — wider viewports just see more of it, narrower viewports see +// less, and the bbox itself is always pixel-aligned with Leaflet's view. +const HERO_WIDTH = 3840; +const HERO_HEIGHT = 2400; +// Zoom-selection reference. Matches the typical desktop hero display size +// (max clamp height = 640 px, full-width up to ~1920 on common monitors) +// so the static image picks the same integer zoom Leaflet's `fitBounds` +// would pick at the live container — meaning the full route is visible on +// the static at every common desktop viewport, no zoom-out animation +// needed once the live map takes over. Narrower viewports still get the +// fly-to-fit animation on top. +const HERO_FIT_WIDTH = 1920; +const HERO_FIT_HEIGHT = 640; +// Nord red — same accent the live HikeMap uses for its polyline, so the +// fade-over from static to interactive looks continuous. +const HERO_TRAIL_COLOR = '#bf616a'; +// Photo-badge fill, border + icon-stroke colours per UI theme. Matches +// the live HikeMap's `.hike-photo-marker .badge`: +// background: var(--color-primary) → Nord10 light / Nord8 dark +// border: var(--color-surface) → Nord6 light / Nord1 dark +// color: var(--color-text-on-primary) → white on the light +// theme's mid-blue primary, Nord0 on the dark theme's +// light-blue primary (which has too little contrast +// against pure white). +const HERO_BADGE_FILL_LIGHT = '#5e81ac'; +const HERO_BADGE_FILL_DARK = '#88c0d0'; +const HERO_BADGE_BORDER_LIGHT = '#eceff4'; +const HERO_BADGE_BORDER_DARK = '#3b4252'; +const HERO_BADGE_ICON_LIGHT = '#ffffff'; +const HERO_BADGE_ICON_DARK = '#2e3440'; +// Bumped whenever the static-map renderer's visual output changes (icons, +// stroke widths, marker shapes, ...) so the per-hike hash invalidates and +// existing files get re-rendered on the next build. +const HERO_RENDER_VERSION = 5; + +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 +> { + if (track.length < 2) return undefined; + + const polyline: Array<[number, number]> = track.map((p) => [p.lat, p.lng]); + // Public photo markers only — the hero is rendered once and served to + // everyone, including logged-out viewers, so private positions must + // not be burned in. + const photoMarkers = imagePoints + .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, + color: HERO_TRAIL_COLOR, + poly: polyline, + photos: photoMarkers, + fill: fillColor, + border: borderColor, + icon: iconColor, + v: HERO_RENDER_VERSION + }) + ) + .digest('hex') + .slice(0, 8); + + 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 }; + } + + 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] + }; +} + // --------------------------------------------------------------------------- // Image EXIF -> ImagePoint // --------------------------------------------------------------------------- @@ -697,11 +847,19 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise.*` files (different hash, not in keepFiles) get removed. - const iconResult = await processIcon(slug, hikeDir); + // Per-route icon + pre-rendered hero map — handled here (before cleanup) + // so their outNames join `keepFiles.images` and survive the orphan sweep, + // while previous-build `icon..*` / `hero..*` files + // (different hash, not in keepFiles) get removed automatically. + const [iconResult, heroResult] = await Promise.all([ + processIcon(slug, hikeDir), + processHero(slug, track, bbox, imagePoints) + ]); if (iconResult) keepFiles.images.add(iconResult.outName); + if (heroResult) { + keepFiles.images.add(heroResult.lightOutName); + keepFiles.images.add(heroResult.darkOutName); + } // Cleanup pass: drop any encoded files in either segment dir that don't // belong to a current image. Catches both stale hashes (deleted source @@ -764,6 +922,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise` in the + * detail page's hero so the user sees an exact replica of the live map + * during the few hundred milliseconds it takes Leaflet to dynamic-import, + * fetch tiles, and render — eliminating the perceived load delay. + * + * Tiles are content-cached on disk; rendered heroes are name-cached by + * content hash so a re-build with unchanged GPX is a no-op. + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import sharp from 'sharp'; + +const TILE_SIZE = 256; +const TILE_CACHE_DIR = path.resolve(process.cwd(), 'scripts', '.cache', 'swisstopo-tiles'); +// Swisstopo serves the WMTS tiles from wmts10–wmts100. Spread across a +// couple of sub-domains so we don't hammer a single origin during initial +// build (browsers see different hosts; the disk cache makes follow-up +// builds a non-event regardless). +const SUBDOMAINS = ['wmts10', 'wmts20'] as const; +const USER_AGENT = 'bocken-homepage build-hikes'; + +function tileUrl(sub: string, layer: string, z: number, x: number, y: number): string { + return `https://${sub}.geo.admin.ch/1.0.0/${layer}/default/current/3857/${z}/${x}/${y}.jpeg`; +} + +/** Web Mercator: lng/lat → absolute pixel coordinate at a given zoom. */ +function lngLatToPx(lng: number, lat: number, zoom: number): { x: number; y: number } { + const n = 2 ** zoom; + const x = ((lng + 180) / 360) * n * TILE_SIZE; + const latRad = (lat * Math.PI) / 180; + const y = + ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n * TILE_SIZE; + return { x, y }; +} + +async function pathExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function fetchTile( + layer: string, + z: number, + x: number, + y: number +): Promise { + const key = `${layer.replace(/[^a-z0-9]/gi, '_')}_${z}_${x}_${y}.jpeg`; + const cachePath = path.join(TILE_CACHE_DIR, key); + try { + return await fs.readFile(cachePath); + } catch { /* miss */ } + + const sub = SUBDOMAINS[(x + y) % SUBDOMAINS.length]; + try { + const res = await fetch(tileUrl(sub, layer, z, x, y), { + headers: { 'User-Agent': USER_AGENT } + }); + if (!res.ok) 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 { + return null; + } +} + +function escapeSvgNumber(n: number): string { + // Keep SVG path compact but precise enough for 1600 px rendering. + return n.toFixed(1); +} + +export interface RenderStaticMapPhotoMarker { + lat: number; + lng: number; +} + +export interface StaticMapPose { + zoom: number; + centerLat: number; + centerLng: number; + /** Origin in zoom-pixel space — top-left of the output canvas. The + * renderer needs it; the caller doesn't, but exposing it keeps the + * `computePose` ↔ `renderStaticMap` interface stateless. */ + originX: number; + originY: number; +} + +export interface ComputeStaticMapPoseOpts { + bbox: [number, number, number, number]; + /** Canvas dimensions for centering / tile fetching. */ + width?: number; + height?: number; + paddingPx?: number; + /** Reference dimensions used purely for zoom selection. Defaults to + * `width × height` — but pass the expected *display* size (not the + * rendered canvas size) when you want zoom to match Leaflet's + * `fitBounds` at the user's viewport. The renderer still draws the + * full `width × height` canvas around the chosen zoom, so wider + * viewports get more context without the bbox being cropped on + * smaller ones. */ + fitWidth?: number; + fitHeight?: number; +} + +/** Pure-math pass: pick the zoom + centre + canvas origin that the static + * renderer would use for these inputs. Identical for light- and dark- + * themed renders, so callers can compute it once and re-use. */ +export function computeStaticMapPose(opts: ComputeStaticMapPoseOpts): StaticMapPose | null { + const width = opts.width ?? 1600; + const height = opts.height ?? 1000; + const paddingPx = opts.paddingPx ?? 24; + const fitWidth = opts.fitWidth ?? width; + const fitHeight = opts.fitHeight ?? height; + + const [minLat, minLng, maxLat, maxLng] = opts.bbox; + if ( + !Number.isFinite(minLat) || !Number.isFinite(minLng) || + !Number.isFinite(maxLat) || !Number.isFinite(maxLng) + ) { + return null; + } + + const innerW = Math.max(1, fitWidth - 2 * paddingPx); + const innerH = Math.max(1, fitHeight - 2 * paddingPx); + + // Pick the highest integer zoom where the bbox fits inside the + // reference inner rectangle. This mirrors Leaflet's `fitBounds` + // 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--) { + const tl = lngLatToPx(minLng, maxLat, z); + const br = lngLatToPx(maxLng, minLat, z); + if (br.x - tl.x <= innerW && br.y - tl.y <= innerH) { + zoom = z; + break; + } + } + + const centerLat = (minLat + maxLat) / 2; + const centerLng = (minLng + maxLng) / 2; + const c = lngLatToPx(centerLng, centerLat, zoom); + const originX = Math.round(c.x - width / 2); + const originY = Math.round(c.y - height / 2); + + return { zoom, centerLat, centerLng, originX, originY }; +} + +export interface RenderStaticMapOpts { + /** Pre-computed pose (zoom + centre + origin). Get this via + * `computeStaticMapPose(...)`. Shared by light- and dark-themed + * renders so both variants align perfectly. */ + pose: StaticMapPose; + /** Track polyline as `[lat, lng]` tuples (any length). */ + polyline: Array<[number, number]>; + color: string; + outputPath: string; + width?: number; + height?: number; + /** Swisstopo WMTS layer ID. Defaults to the schematic Pixelkarte (the + * same base layer Leaflet starts with on the detail page). */ + layer?: string; + /** Optional image-point markers to burn into the SVG overlay alongside + * the start/end dots. Pass only the points safe to render in a public- + * facing image — private photos should be filtered out by the caller. */ + photoMarkers?: RenderStaticMapPhotoMarker[]; + /** Fill colour for the photo marker dots. Should match the live + * HikePhoto marker styling (`--color-primary`). */ + photoMarkerColor?: string; + /** Border colour for the photo marker dots — matches the live + * `.hike-photo-marker .badge` `border-color: var(--color-surface)` so + * the static blends in with the active theme's surface colour. */ + photoMarkerBorderColor?: string; + /** Stroke colour of the Lucide `camera` icon inside the badge. Matches + * the live badge's `color: var(--color-text-on-primary)` — white on + * the light theme's mid-blue primary, dark on the dark theme's light- + * blue primary. */ + 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; + + 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. + 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++) { + tileJobs.push({ + tx, + ty, + left: tx * TILE_SIZE - originX, + top: ty * TILE_SIZE - originY + }); + } + } + const tileBufs = await Promise.all( + tileJobs.map(async (job) => ({ + job, + buf: await fetchTile(layer, zoom, job.tx, job.ty) + })) + ); + + const composites: Array<{ input: Buffer; left: number; top: number }> = []; + let fetched = 0; + for (const { job, buf } of tileBufs) { + if (!buf) continue; + fetched++; + 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; + + // 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({ + 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. + const pathParts: string[] = []; + for (let i = 0; i < opts.polyline.length; i++) { + const [lat, lng] = opts.polyline[i]; + const p = lngLatToPx(lng, lat, zoom); + const px = p.x - originX; + const py = p.y - originY; + pathParts.push((i === 0 ? 'M' : 'L') + escapeSvgNumber(px) + ',' + escapeSvgNumber(py)); + } + const start = opts.polyline[0]; + const end = opts.polyline[opts.polyline.length - 1]; + const startP = lngLatToPx(start[1], start[0], zoom); + const endP = lngLatToPx(end[1], end[0], zoom); + const sx = escapeSvgNumber(startP.x - originX); + const sy = escapeSvgNumber(startP.y - originY); + const ex = escapeSvgNumber(endP.x - originX); + const ey = escapeSvgNumber(endP.y - originY); + + const photoMarkerColor = opts.photoMarkerColor ?? '#5e81ac'; + const photoMarkerBorderColor = opts.photoMarkerBorderColor ?? '#eceff4'; + const photoMarkerIconColor = opts.photoMarkerIconColor ?? '#fff'; + // Match HikeMap's `.hike-photo-marker .badge` — 28 px Nord-blue circle + // with a 2 px theme-surface border, holding a 14 px theme-on-primary + // Lucide `camera` icon. The camera icon paths are the literal Lucide + // source (lucide-camera). + const photoMarkers = (opts.photoMarkers ?? []) + .map((m) => { + const p = lngLatToPx(m.lng, m.lat, zoom); + const cx = escapeSvgNumber(p.x - originX); + const cy = escapeSvgNumber(p.y - originY); + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); + }) + .join(''); + + const overlay = Buffer.from( + `` + + `` + + photoMarkers + + `` + + `` + + `` + ); + + 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/HikeMap.svelte b/src/lib/components/hikes/HikeMap.svelte index f4a27cc3..a621c78f 100644 --- a/src/lib/components/hikes/HikeMap.svelte +++ b/src/lib/components/hikes/HikeMap.svelte @@ -19,9 +19,26 @@ /** When false, private images are hidden — anonymous viewers only see * public ones. Logged-in users get the full set. */ showPrivate?: boolean; + /** Initial map centre `[lat, lng]`. When provided alongside + * `initialZoom`, the map opens with `setView(center, zoom)` instead + * of `fitBounds(track)` — used by the detail page to align Leaflet's + * first paint with the SSR-rendered static hero map. */ + 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 detail + * page uses this to fade out the SSR-rendered static hero. */ + onReady?: () => void; } - const { track, imagePoints = [], showPrivate = false }: Props = $props(); + const { + track, + imagePoints = [], + showPrivate = false, + initialCenter, + initialZoom, + onReady + }: Props = $props(); // User-location toggle moved inside the map UI. localStorage-persisted so // returning visitors get the same state. Permission errors surface as a @@ -145,6 +162,53 @@ tileLayers.schematic.addTo(map); let currentBase: BaseLayer = 'schematic'; + // 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` + // view, which is typically slightly more zoomed out. The fade + // (~450 ms) overlaps with the zoom animation (~700 ms) so the + // user sees the map ease into its proper framing as the static + // dissolves. + tileLayers.schematic.once('load', () => { + // 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); + }); + }); + // Canvas-rendered polylines can't resolve CSS custom properties, so read // the trail color from the document at mount time. Nord red contrasts // strongly against both the schematic map and the aerial imagery. @@ -173,7 +237,15 @@ weight: 2 }).addTo(map); const initialBounds = polyline.getBounds(); - map.fitBounds(initialBounds, { padding: [24, 24] }); + // When the caller supplies a specific center+zoom (e.g. the detail + // page handing over from a pre-rendered static hero), open with + // `setView` so Leaflet lands on the exact same pose the static + // image was rendered at. Otherwise fall back to fitBounds. + if (initialCenter && typeof initialZoom === 'number') { + map.setView(initialCenter, initialZoom, { animate: false }); + } else { + map.fitBounds(initialBounds, { padding: [24, 24] }); + } // Expose a re-focus callback that re-fits the polyline bounds — // the same view the user started with after dragging or zooming diff --git a/src/routes/hikes/[slug]/+page.svelte b/src/routes/hikes/[slug]/+page.svelte index 0d6e8484..c8a9d0c9 100644 --- a/src/routes/hikes/[slug]/+page.svelte +++ b/src/routes/hikes/[slug]/+page.svelte @@ -26,6 +26,10 @@ let track = $state(null); let trackError = $state(null); + // Toggled true once Leaflet's first tile batch paints. Drives the + // fade-out of the SSR-rendered static hero so the static→interactive + // handover is a soft cross-fade rather than a swap. + let heroMapReady = $state(false); $effect(() => { let aborted = false; @@ -227,11 +231,48 @@ HikeMap further down sticks in the scroll-area; both share state via the focusedImageStore so they animate together. -->
+ {#if hike.heroMapUrlLight} + + + {/if} + {#if hike.heroMapUrlDark} + + {/if} {#if track && track.length > 0} - + (heroMapReady = true)} + /> {:else if trackError}
Track konnte nicht geladen werden: {trackError}
- {:else} + {:else if !hike.heroMapUrl}
Track wird geladen…
{/if}
@@ -351,19 +392,75 @@ .hero-map { position: relative; width: 100vw; + /* Reserve the eventual map height up-front so the page doesn't shift + * once the track JSON arrives and HikeMap mounts. Same clamp as + * `.hero-map :global(.map)` so the container and the leaflet pane + * are always congruent. */ + min-height: clamp(360px, 60vh, 640px); 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 any tile area not yet painted shows the page + * background through — which already adapts to the active theme. + * Leaflet's default `#ddd` container background is overridden in + * the `.map` rule below. */ + background: transparent; } .hero-map :global(.map) { + position: relative; + z-index: 2; height: clamp(360px, 60vh, 640px); border-radius: 0; box-shadow: none; + /* Stay transparent so the SSR-rendered static map underneath shows + * through until Leaflet's tilepane paints over it. */ + background: transparent; } + /* Static hero map (pre-rendered Swisstopo composite). Displayed at + * NATIVE pixel size (`object-fit: none`) and centred — `cover` would + * scale the image and break the 1:1 pixel match with Leaflet's tile + * rendering, which is what caused the visible shift during cross- + * fade. Wider viewports just show a slightly-cropped band of the + * full image; the central region (where the trail lives) 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; + } + + .hero-static.faded { + opacity: 0; + /* Once faded the live map is fully in charge; ensure the static + * image doesn't intercept hovers/clicks meant for the leaflet + * panes underneath. */ + 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; } + @media (prefers-color-scheme: dark) { + .hero-static-light { display: none; } + .hero-static-dark { 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 * they aren't covered on narrow viewports where the nav spans the * full width. The bottom-right controls (layer toggle, photo toggle, @@ -626,10 +723,12 @@ } .map-fallback { - padding: 4rem 1rem; + display: grid; + place-items: center; + height: clamp(360px, 60vh, 640px); + padding: 1rem; text-align: center; color: var(--color-text-tertiary); - background: var(--color-surface); - border-radius: var(--radius-card); + background: var(--color-bg-elevated); } diff --git a/src/types/hikes.ts b/src/types/hikes.ts index 2ee5ee6a..65cd2725 100644 --- a/src/types/hikes.ts +++ b/src/types/hikes.ts @@ -74,6 +74,23 @@ export type HikeManifestEntry = { * to a small WebP. */ icon?: string; + /** Pre-rendered hero map (light theme): Swisstopo tiles composited at + * build time with the trail polyline + photo markers + start/end dots + * burned in. Rendered as `` in the detail page so the user sees + * a real map immediately; Leaflet hydrates on top once the track JSON + * 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. */ + 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 + * the static→interactive handover doesn't visibly shift the map. */ + heroMapZoom?: number; + /** Map centre `[lat, lng]` the static hero was rendered around. */ + heroMapCenter?: [number, number]; + // Geo-tagged photos shown as map markers on the detail page: imagePoints: ImagePoint[]; };