feat(hikes): pre-rendered static hero map with smooth Leaflet handover
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 `<img>` 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.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.71.0",
|
"version": "1.72.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+171
-5
@@ -27,6 +27,7 @@ import {
|
|||||||
type GpxPoint
|
type GpxPoint
|
||||||
} from '../src/lib/server/gpx.js';
|
} from '../src/lib/server/gpx.js';
|
||||||
import { simplifyTrack } from '../src/lib/server/simplifyTrack.js';
|
import { simplifyTrack } from '../src/lib/server/simplifyTrack.js';
|
||||||
|
import { computeStaticMapPose, renderStaticMap } from './staticHikeMap.js';
|
||||||
import type {
|
import type {
|
||||||
Difficulty,
|
Difficulty,
|
||||||
HikeManifestEntry,
|
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_SMOOTH_WINDOW = 5; // moving-average window for altitude denoising
|
||||||
const ELEV_MIN_STEP_M = 3; // discard altitude deltas below this
|
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_WIDTHS = [480, 960, 1600] as const;
|
||||||
const IMAGE_THUMBNAIL_WIDTH = 240; // popup thumbnail for map markers
|
const IMAGE_THUMBNAIL_WIDTH = 240; // popup thumbnail for map markers
|
||||||
const MANIFEST_WARN_BYTES = 200_000;
|
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 };
|
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
|
// Image EXIF -> ImagePoint
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -697,11 +847,19 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
if (r.point) imagePoints.push(r.point);
|
if (r.point) imagePoints.push(r.point);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-route icon — handled here (before cleanup) so its outName joins
|
// Per-route icon + pre-rendered hero map — handled here (before cleanup)
|
||||||
// `keepFiles.images` and survives the orphan sweep, while previous-build
|
// so their outNames join `keepFiles.images` and survive the orphan sweep,
|
||||||
// `icon.<oldhash>.*` files (different hash, not in keepFiles) get removed.
|
// while previous-build `icon.<oldhash>.*` / `hero.<oldhash>.*` files
|
||||||
const iconResult = await processIcon(slug, hikeDir);
|
// (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 (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
|
// Cleanup pass: drop any encoded files in either segment dir that don't
|
||||||
// belong to a current image. Catches both stale hashes (deleted source
|
// belong to a current image. Catches both stale hashes (deleted source
|
||||||
@@ -764,6 +922,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
const tags = Array.isArray(fm.tags) ? fm.tags : [];
|
const tags = Array.isArray(fm.tags) ? fm.tags : [];
|
||||||
|
|
||||||
const iconUrl = iconResult?.url;
|
const iconUrl = iconResult?.url;
|
||||||
|
const heroMapUrlLight = heroResult?.lightUrl;
|
||||||
|
const heroMapUrlDark = heroResult?.darkUrl;
|
||||||
|
const heroMapZoom = heroResult?.zoom;
|
||||||
|
const heroMapCenter = heroResult?.center;
|
||||||
|
|
||||||
const entry: HikeManifestEntry = {
|
const entry: HikeManifestEntry = {
|
||||||
slug,
|
slug,
|
||||||
@@ -791,6 +953,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
pointCount: track.length,
|
pointCount: track.length,
|
||||||
cover,
|
cover,
|
||||||
icon: iconUrl,
|
icon: iconUrl,
|
||||||
|
heroMapUrlLight,
|
||||||
|
heroMapUrlDark,
|
||||||
|
heroMapZoom,
|
||||||
|
heroMapCenter,
|
||||||
imagePoints
|
imagePoints
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* Build-time static hero-map renderer for individual hikes.
|
||||||
|
*
|
||||||
|
* Fetches the Swisstopo raster tiles covering each hike's bbox, composites
|
||||||
|
* them into one PNG via sharp, draws the trail polyline + start/end markers
|
||||||
|
* on top, and emits a single WebP. The result is served as `<img>` 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<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTile(
|
||||||
|
layer: string,
|
||||||
|
z: number,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
): Promise<Buffer | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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 (
|
||||||
|
`<g transform="translate(${cx} ${cy})">` +
|
||||||
|
`<circle r="14" fill="${photoMarkerColor}" stroke="${photoMarkerBorderColor}" stroke-width="2"/>` +
|
||||||
|
`<g transform="translate(-7 -7) scale(0.5833)" stroke="${photoMarkerIconColor}" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">` +
|
||||||
|
`<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/>` +
|
||||||
|
`<circle cx="12" cy="13" r="3"/>` +
|
||||||
|
`</g>` +
|
||||||
|
`</g>`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const overlay = Buffer.from(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
||||||
|
`<path d="${pathParts.join(' ')}" fill="none" stroke="${opts.color}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.95"/>` +
|
||||||
|
photoMarkers +
|
||||||
|
`<circle cx="${sx}" cy="${sy}" r="9" fill="#a3be8c" stroke="#fff" stroke-width="3"/>` +
|
||||||
|
`<circle cx="${ex}" cy="${ey}" r="9" fill="#bf616a" stroke="#fff" stroke-width="3"/>` +
|
||||||
|
`</svg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
await sharp(mapBuf)
|
||||||
|
.composite([{ input: overlay, left: 0, top: 0 }])
|
||||||
|
.webp({ quality: 78 })
|
||||||
|
.toFile(opts.outputPath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -19,9 +19,26 @@
|
|||||||
/** When false, private images are hidden — anonymous viewers only see
|
/** When false, private images are hidden — anonymous viewers only see
|
||||||
* public ones. Logged-in users get the full set. */
|
* public ones. Logged-in users get the full set. */
|
||||||
showPrivate?: boolean;
|
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
|
// User-location toggle moved inside the map UI. localStorage-persisted so
|
||||||
// returning visitors get the same state. Permission errors surface as a
|
// returning visitors get the same state. Permission errors surface as a
|
||||||
@@ -145,6 +162,53 @@
|
|||||||
tileLayers.schematic.addTo(map);
|
tileLayers.schematic.addTo(map);
|
||||||
let currentBase: BaseLayer = 'schematic';
|
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
|
// Canvas-rendered polylines can't resolve CSS custom properties, so read
|
||||||
// the trail color from the document at mount time. Nord red contrasts
|
// the trail color from the document at mount time. Nord red contrasts
|
||||||
// strongly against both the schematic map and the aerial imagery.
|
// strongly against both the schematic map and the aerial imagery.
|
||||||
@@ -173,7 +237,15 @@
|
|||||||
weight: 2
|
weight: 2
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
const initialBounds = polyline.getBounds();
|
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 —
|
// Expose a re-focus callback that re-fits the polyline bounds —
|
||||||
// the same view the user started with after dragging or zooming
|
// the same view the user started with after dragging or zooming
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
|
|
||||||
let track = $state<HikeTrackPoint[] | null>(null);
|
let track = $state<HikeTrackPoint[] | null>(null);
|
||||||
let trackError = $state<string | null>(null);
|
let trackError = $state<string | null>(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(() => {
|
$effect(() => {
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
@@ -227,11 +231,48 @@
|
|||||||
HikeMap further down sticks in the scroll-area; both share state via
|
HikeMap further down sticks in the scroll-area; both share state via
|
||||||
the focusedImageStore so they animate together. -->
|
the focusedImageStore so they animate together. -->
|
||||||
<section class="hero-map" style="view-transition-name: hike-{hike.slug}">
|
<section class="hero-map" style="view-transition-name: hike-{hike.slug}">
|
||||||
|
{#if hike.heroMapUrlLight}
|
||||||
|
<!-- Build-time static composite of Swisstopo tiles + the trail
|
||||||
|
polyline + public photo markers. Two variants ship (light
|
||||||
|
and dark); CSS picks the right one based on `data-theme`
|
||||||
|
with `prefers-color-scheme` as the fallback. Both are
|
||||||
|
rendered at the same zoom + centre as the live Leaflet
|
||||||
|
map, and displayed at native pixel size (object-fit: none)
|
||||||
|
so they overlay the live tiles exactly. The image fades
|
||||||
|
out once Leaflet's first tile batch loads. -->
|
||||||
|
<img
|
||||||
|
class="hero-static hero-static-light"
|
||||||
|
class:faded={heroMapReady}
|
||||||
|
src={hike.heroMapUrlLight}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if hike.heroMapUrlDark}
|
||||||
|
<img
|
||||||
|
class="hero-static hero-static-dark"
|
||||||
|
class:faded={heroMapReady}
|
||||||
|
src={hike.heroMapUrlDark}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if track && track.length > 0}
|
{#if track && track.length > 0}
|
||||||
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate />
|
<HikeMap
|
||||||
|
{track}
|
||||||
|
imagePoints={visibleImagePoints}
|
||||||
|
showPrivate
|
||||||
|
initialCenter={hike.heroMapCenter}
|
||||||
|
initialZoom={hike.heroMapZoom}
|
||||||
|
onReady={() => (heroMapReady = true)}
|
||||||
|
/>
|
||||||
{:else if trackError}
|
{:else if trackError}
|
||||||
<div class="map-fallback">Track konnte nicht geladen werden: {trackError}</div>
|
<div class="map-fallback">Track konnte nicht geladen werden: {trackError}</div>
|
||||||
{:else}
|
{:else if !hike.heroMapUrl}
|
||||||
<div class="map-fallback">Track wird geladen…</div>
|
<div class="map-fallback">Track wird geladen…</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="hero-title">
|
<div class="hero-title">
|
||||||
@@ -351,19 +392,75 @@
|
|||||||
.hero-map {
|
.hero-map {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
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-left: calc(50% - 50vw);
|
||||||
margin-right: calc(50% - 50vw);
|
margin-right: calc(50% - 50vw);
|
||||||
margin-top: calc(-1 * (3rem + max(12px, env(safe-area-inset-top, 0px) + 4px)));
|
margin-top: calc(-1 * (3rem + max(12px, env(safe-area-inset-top, 0px) + 4px)));
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
overflow: hidden;
|
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) {
|
.hero-map :global(.map) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
height: clamp(360px, 60vh, 640px);
|
height: clamp(360px, 60vh, 640px);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
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 `<html>` 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
|
/* Push Leaflet's top-left controls (zoom +/-) below the sticky nav so
|
||||||
* they aren't covered on narrow viewports where the nav spans the
|
* they aren't covered on narrow viewports where the nav spans the
|
||||||
* full width. The bottom-right controls (layer toggle, photo toggle,
|
* full width. The bottom-right controls (layer toggle, photo toggle,
|
||||||
@@ -626,10 +723,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.map-fallback {
|
.map-fallback {
|
||||||
padding: 4rem 1rem;
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: clamp(360px, 60vh, 640px);
|
||||||
|
padding: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
background: var(--color-surface);
|
background: var(--color-bg-elevated);
|
||||||
border-radius: var(--radius-card);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ export type HikeManifestEntry = {
|
|||||||
* to a small WebP. */
|
* to a small WebP. */
|
||||||
icon?: string;
|
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 `<img>` 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:
|
// Geo-tagged photos shown as map markers on the detail page:
|
||||||
imagePoints: ImagePoint[];
|
imagePoints: ImagePoint[];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user