feat(hikes): pre-rendered overview hero map with same handover pattern
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.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.72.0",
|
"version": "1.73.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+137
-3
@@ -27,10 +27,11 @@ 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 { computeStaticMapPose, renderOverviewMap, renderStaticMap } from './staticHikeMap.js';
|
||||||
import type {
|
import type {
|
||||||
Difficulty,
|
Difficulty,
|
||||||
HikeManifestEntry,
|
HikeManifestEntry,
|
||||||
|
HikesOverview,
|
||||||
ImagePoint,
|
ImagePoint,
|
||||||
ImageVariant
|
ImageVariant
|
||||||
} from '../src/types/hikes.js';
|
} from '../src/types/hikes.js';
|
||||||
@@ -586,6 +587,133 @@ const HERO_BADGE_ICON_DARK = '#2e3440';
|
|||||||
// existing files get re-rendered on the next build.
|
// existing files get re-rendered on the next build.
|
||||||
const HERO_RENDER_VERSION = 5;
|
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<Difficulty, string> = {
|
||||||
|
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<HikesOverview | undefined> {
|
||||||
|
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(
|
async function processHero(
|
||||||
slug: string,
|
slug: string,
|
||||||
track: GpxPoint[],
|
track: GpxPoint[],
|
||||||
@@ -990,11 +1118,17 @@ async function main() {
|
|||||||
|
|
||||||
hikes.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
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 });
|
await fs.mkdir(path.dirname(MANIFEST_OUT), { recursive: true });
|
||||||
const banner =
|
const banner =
|
||||||
'// AUTO-GENERATED by scripts/build-hikes.ts — do not edit by hand.\n' +
|
'// AUTO-GENERATED by scripts/build-hikes.ts — do not edit by hand.\n' +
|
||||||
"import type { HikeManifestEntry } from '$types/hikes';\n\n";
|
"import type { HikeManifestEntry, HikesOverview } from '$types/hikes';\n\n";
|
||||||
const body = `export const HIKES: HikeManifestEntry[] = ${JSON.stringify(hikes, null, 2)} as const;\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;
|
const manifestSrc = banner + body;
|
||||||
await fs.writeFile(MANIFEST_OUT, manifestSrc);
|
await fs.writeFile(MANIFEST_OUT, manifestSrc);
|
||||||
|
|
||||||
|
|||||||
+138
-28
@@ -48,12 +48,19 @@ async function pathExists(p: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `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(
|
async function fetchTile(
|
||||||
layer: string,
|
layer: string,
|
||||||
z: number,
|
z: number,
|
||||||
x: number,
|
x: number,
|
||||||
y: number
|
y: number
|
||||||
): Promise<Buffer | null> {
|
): Promise<TileResult> {
|
||||||
const key = `${layer.replace(/[^a-z0-9]/gi, '_')}_${z}_${x}_${y}.jpeg`;
|
const key = `${layer.replace(/[^a-z0-9]/gi, '_')}_${z}_${x}_${y}.jpeg`;
|
||||||
const cachePath = path.join(TILE_CACHE_DIR, key);
|
const cachePath = path.join(TILE_CACHE_DIR, key);
|
||||||
try {
|
try {
|
||||||
@@ -65,12 +72,23 @@ async function fetchTile(
|
|||||||
const res = await fetch(tileUrl(sub, layer, z, x, y), {
|
const res = await fetch(tileUrl(sub, layer, z, x, y), {
|
||||||
headers: { 'User-Agent': USER_AGENT }
|
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());
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
await fs.mkdir(TILE_CACHE_DIR, { recursive: true });
|
await fs.mkdir(TILE_CACHE_DIR, { recursive: true });
|
||||||
await fs.writeFile(cachePath, buf);
|
await fs.writeFile(cachePath, buf);
|
||||||
return buf;
|
return buf;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (process.env.STATIC_MAP_DEBUG) {
|
||||||
|
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} error:`, err);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +129,10 @@ export interface ComputeStaticMapPoseOpts {
|
|||||||
* smaller ones. */
|
* smaller ones. */
|
||||||
fitWidth?: number;
|
fitWidth?: number;
|
||||||
fitHeight?: 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
|
/** 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 paddingPx = opts.paddingPx ?? 24;
|
||||||
const fitWidth = opts.fitWidth ?? width;
|
const fitWidth = opts.fitWidth ?? width;
|
||||||
const fitHeight = opts.fitHeight ?? height;
|
const fitHeight = opts.fitHeight ?? height;
|
||||||
|
const maxZoom = opts.maxZoom ?? 18;
|
||||||
|
|
||||||
const [minLat, minLng, maxLat, maxLng] = opts.bbox;
|
const [minLat, minLng, maxLat, maxLng] = opts.bbox;
|
||||||
if (
|
if (
|
||||||
@@ -139,7 +162,7 @@ export function computeStaticMapPose(opts: ComputeStaticMapPoseOpts): StaticMapP
|
|||||||
// integer-zoom search, so a viewport matching `fitWidth × fitHeight`
|
// integer-zoom search, so a viewport matching `fitWidth × fitHeight`
|
||||||
// will choose the same zoom Leaflet does for the same bbox.
|
// will choose the same zoom Leaflet does for the same bbox.
|
||||||
let zoom = 7;
|
let zoom = 7;
|
||||||
for (let z = 18; z >= 7; z--) {
|
for (let z = maxZoom; z >= 7; z--) {
|
||||||
const tl = lngLatToPx(minLng, maxLat, z);
|
const tl = lngLatToPx(minLng, maxLat, z);
|
||||||
const br = lngLatToPx(maxLng, minLat, z);
|
const br = lngLatToPx(maxLng, minLat, z);
|
||||||
if (br.x - tl.x <= innerW && br.y - tl.y <= innerH) {
|
if (br.x - tl.x <= innerW && br.y - tl.y <= innerH) {
|
||||||
@@ -189,24 +212,28 @@ export interface RenderStaticMapOpts {
|
|||||||
photoMarkerIconColor?: string;
|
photoMarkerIconColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render and write a single static hero map at the given pose. Returns
|
/** Fetch every Swisstopo tile covering the canvas at the given pose, then
|
||||||
* `false` on failure (zero tiles fetched, degenerate inputs). */
|
* composite them into a single PNG buffer. Returns `null` when fewer than
|
||||||
export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolean> {
|
* half the tiles arrive (a patchy hero is worse than no hero). Shared by
|
||||||
const width = opts.width ?? 1600;
|
* `renderStaticMap` (per-hike hero) and `renderOverviewMap` (the /hikes
|
||||||
const height = opts.height ?? 1000;
|
* landing-page hero) so both pull the same tile cache and use the same
|
||||||
const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe';
|
* fallback colour. */
|
||||||
const { zoom, originX, originY } = opts.pose;
|
async function composeBaseMap(
|
||||||
|
pose: StaticMapPose,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
layer: string
|
||||||
|
): Promise<Buffer | null> {
|
||||||
|
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 minTileX = Math.floor(originX / TILE_SIZE);
|
||||||
const maxTileX = Math.floor((originX + width - 1) / TILE_SIZE);
|
const maxTileX = Math.floor((originX + width - 1) / TILE_SIZE);
|
||||||
const minTileY = Math.floor(originY / TILE_SIZE);
|
const minTileY = Math.floor(originY / TILE_SIZE);
|
||||||
const maxTileY = Math.floor((originY + height - 1) / TILE_SIZE);
|
const maxTileY = Math.floor((originY + height - 1) / TILE_SIZE);
|
||||||
|
|
||||||
// Parallel tile fetches — disk cache makes follow-up builds essentially
|
// 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 }> = [];
|
const tileJobs: Array<{ tx: number; ty: number; left: number; top: number }> = [];
|
||||||
for (let ty = minTileY; ty <= maxTileY; ty++) {
|
for (let ty = minTileY; ty <= maxTileY; ty++) {
|
||||||
for (let tx = minTileX; tx <= maxTileX; tx++) {
|
for (let tx = minTileX; tx <= maxTileX; tx++) {
|
||||||
@@ -226,29 +253,46 @@ export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolea
|
|||||||
);
|
);
|
||||||
|
|
||||||
const composites: Array<{ input: Buffer; left: number; top: number }> = [];
|
const composites: Array<{ input: Buffer; left: number; top: number }> = [];
|
||||||
let fetched = 0;
|
let networkFailures = 0;
|
||||||
for (const { job, buf } of tileBufs) {
|
for (const { job, buf } of tileBufs) {
|
||||||
if (!buf) continue;
|
if (buf === null) {
|
||||||
fetched++;
|
networkFailures++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (buf === 'blank') continue; // out-of-bounds, draw the fallback grey
|
||||||
composites.push({ input: buf, left: job.left, top: job.top });
|
composites.push({ input: buf, left: job.left, top: job.top });
|
||||||
}
|
}
|
||||||
// Abandon when fewer than half the tiles arrived — the result would
|
// Network-failure threshold (not "fewer than half present"): blank
|
||||||
// be too patchy to ship and we'd rather show no static map than a
|
// out-of-bounds tiles are an expected outcome for the overview hero
|
||||||
// confusing one.
|
// that extends past Switzerland's edges, so they don't count against
|
||||||
if (fetched < tileJobs.length / 2) return false;
|
// the abort threshold.
|
||||||
|
if (networkFailures > tileJobs.length / 2) return null;
|
||||||
|
|
||||||
// Step 1: build the bare map tile composite. Tile composite is identical
|
// Tile composite is identical regardless of UI theme — we deliberately
|
||||||
// regardless of UI theme — we deliberately don't invert the Pixelkarte
|
// don't invert the Pixelkarte for dark mode (its colour palette doesn't
|
||||||
// for dark mode (its colour palette doesn't survive a naive invert).
|
// survive a naive invert). Only the SVG overlay above changes per theme.
|
||||||
// Only the SVG overlay below changes per theme.
|
return sharp({
|
||||||
const mapBuf = await sharp({
|
|
||||||
create: { width, height, channels: 3, background: { r: 235, g: 235, b: 235 } }
|
create: { width, height, channels: 3, background: { r: 235, g: 235, b: 235 } }
|
||||||
})
|
})
|
||||||
.composite(composites)
|
.composite(composites)
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.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<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;
|
||||||
|
|
||||||
|
const mapBuf = await composeBaseMap(opts.pose, width, height, layer);
|
||||||
|
if (!mapBuf) return false;
|
||||||
|
|
||||||
|
// SVG overlay — polyline + photo markers + start/end dots.
|
||||||
const pathParts: string[] = [];
|
const pathParts: string[] = [];
|
||||||
for (let i = 0; i < opts.polyline.length; i++) {
|
for (let i = 0; i < opts.polyline.length; i++) {
|
||||||
const [lat, lng] = opts.polyline[i];
|
const [lat, lng] = opts.polyline[i];
|
||||||
@@ -306,3 +350,69 @@ export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolea
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overview hero (one image for the whole /hikes index page).
|
||||||
|
// Same tile composite as `renderStaticMap`, but the overlay draws many
|
||||||
|
// polylines (one per hike, coloured by SAC tier) and no per-route start /
|
||||||
|
// end / photo markers — the map is a finder, not a detail view.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RenderOverviewPolyline {
|
||||||
|
points: Array<[number, number]>;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderOverviewMapOpts {
|
||||||
|
pose: StaticMapPose;
|
||||||
|
polylines: RenderOverviewPolyline[];
|
||||||
|
outputPath: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
layer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderOverviewMap(opts: RenderOverviewMapOpts): 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;
|
||||||
|
|
||||||
|
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 <path> 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 (
|
||||||
|
`<path d="${parts.join(' ')}" fill="none" stroke="${line.color}" ` +
|
||||||
|
`stroke-width="4" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.9"/>`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const overlay = Buffer.from(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
||||||
|
paths +
|
||||||
|
`</svg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
await sharp(mapBuf)
|
||||||
|
.composite([{ input: overlay, left: 0, top: 0 }])
|
||||||
|
.webp({ quality: 78 })
|
||||||
|
.toFile(opts.outputPath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,9 +13,19 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hikes: HikeManifestEntry[];
|
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
|
// Per-tier polyline colour, matching the painted-marker scheme on the
|
||||||
// SAC badges. Canvas-rendered polylines can't resolve CSS variables,
|
// SAC badges. Canvas-rendered polylines can't resolve CSS variables,
|
||||||
@@ -103,7 +113,17 @@
|
|||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
preferCanvas: 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<BaseLayer, ReturnType<typeof L.tileLayer>> = {
|
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
|
||||||
schematic: L.tileLayer(SWISSTOPO_FARBE, {
|
schematic: L.tileLayer(SWISSTOPO_FARBE, {
|
||||||
@@ -128,8 +148,44 @@
|
|||||||
tileLayers.schematic.addTo(map);
|
tileLayers.schematic.addTo(map);
|
||||||
let currentBase: BaseLayer = 'schematic';
|
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<typeof L.latLngBounds> | 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-
|
// 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 layer = L.layerGroup().addTo(map);
|
||||||
const bounds = L.latLngBounds([]);
|
const bounds = L.latLngBounds([]);
|
||||||
for (const hike of hikes) {
|
for (const hike of hikes) {
|
||||||
@@ -164,10 +220,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let initialBounds: ReturnType<typeof L.latLngBounds> | null = null;
|
|
||||||
if (bounds.isValid()) {
|
if (bounds.isValid()) {
|
||||||
map.fitBounds(bounds, { padding: [32, 32], maxZoom: 13 });
|
|
||||||
initialBounds = bounds;
|
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 = () => {
|
recenterMap = () => {
|
||||||
if (!initialBounds) return;
|
if (!initialBounds) return;
|
||||||
map.flyToBounds(initialBounds, {
|
map.flyToBounds(initialBounds, {
|
||||||
|
|||||||
@@ -4,11 +4,17 @@
|
|||||||
import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.svelte';
|
import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.svelte';
|
||||||
import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte';
|
import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte';
|
||||||
import Seo from '$lib/components/Seo.svelte';
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
|
import { HIKES_OVERVIEW } from '$lib/data/hikes.generated';
|
||||||
import type { Difficulty } from '$types/hikes';
|
import type { Difficulty } from '$types/hikes';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
const { data }: PageProps = $props();
|
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
|
// Filter ceilings start wide-open so the initial render (SSR + first
|
||||||
// hydration pass) shows every hike. `$effect` below clamps them down
|
// hydration pass) shows every hike. `$effect` below clamps them down
|
||||||
// to the actual data maxes once `data.hikes` is fully populated —
|
// to the actual data maxes once `data.hikes` is fully populated —
|
||||||
@@ -80,7 +86,31 @@
|
|||||||
|
|
||||||
<section class="hikes-page">
|
<section class="hikes-page">
|
||||||
<section class="hero-map" aria-label="Übersicht">
|
<section class="hero-map" aria-label="Übersicht">
|
||||||
<HikesOverviewMap hikes={visible} />
|
{#if HIKES_OVERVIEW}
|
||||||
|
<!-- Build-time static composite of Swisstopo tiles + every
|
||||||
|
visible hike's preview polyline, coloured by SAC tier.
|
||||||
|
Displayed at native pixel size (`object-fit: none`) so it
|
||||||
|
overlays Leaflet's live tiles exactly. The image fades out
|
||||||
|
once Leaflet's first tile batch loads. Unlike the detail
|
||||||
|
hero, the overview map looks the same in light and dark
|
||||||
|
mode (only the per-hike camera badges are theme-aware,
|
||||||
|
and the overview has none) so a single variant ships. -->
|
||||||
|
<img
|
||||||
|
class="hero-static"
|
||||||
|
class:faded={heroMapReady}
|
||||||
|
src={HIKES_OVERVIEW.url}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<HikesOverviewMap
|
||||||
|
hikes={visible}
|
||||||
|
initialCenter={HIKES_OVERVIEW?.center}
|
||||||
|
initialZoom={HIKES_OVERVIEW?.zoom}
|
||||||
|
onReady={() => (heroMapReady = true)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="below-hero">
|
<div class="below-hero">
|
||||||
@@ -130,11 +160,50 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
width: 100vw;
|
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-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 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. */
|
/* Push Leaflet's top-left controls below the sticky nav. */
|
||||||
|
|||||||
@@ -94,3 +94,17 @@ export type HikeManifestEntry = {
|
|||||||
// 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[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 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];
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user