feat(hikes): phone-sized static hero variant for ≤560 px viewports
The wide static hero picks its zoom for a desktop-sized container (fitWidth 1920), so on phones the bbox lands too zoomed-in: most of the route falls outside the visible 400 CSS px band. Build now emits a second pose per hero — rendered with fitWidth 400 / fitHeight 480 onto a 1200² canvas — so the auto-fit zoom matches what Leaflet picks at the same container size. Per-hike hero gains four variants total (theme × viewport); overview hero gains two. The page picks which `<img>` to show via a `max-width: 560px` media query (no JS needed for the swap), and `matchMedia` decides which pose to hand to Leaflet's first `setView` so the static→live cross- fade aligns regardless of viewport. Drive-by: replace the long-stale `hike.heroMapUrl` reference in the detail page's track-loading fallback with `hike.heroMapUrlLight`.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.73.0",
|
"version": "1.74.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+183
-66
@@ -587,6 +587,46 @@ 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;
|
||||||
|
|
||||||
|
// Narrow-viewport variant for phones (≤ 560 px CSS width). Same renderer,
|
||||||
|
// but the pose is picked for a phone-sized container so the auto-fit zoom
|
||||||
|
// matches what Leaflet computes there. Canvas stays modest (1200²) since
|
||||||
|
// the image only needs to cover phone viewports — wider screens fall back
|
||||||
|
// to the wide hero. `object-fit: none` again pins the centre to the
|
||||||
|
// container midpoint, so any extra image bleed shows on the edges only.
|
||||||
|
const HERO_NARROW_WIDTH = 1200;
|
||||||
|
const HERO_NARROW_HEIGHT = 1200;
|
||||||
|
// Typical phone hero: ~400 CSS px wide (median portrait phone),
|
||||||
|
// `clamp(360, 60vh, 640)` ≈ 480 tall on a ~800 px screen. Pick a
|
||||||
|
// representative square so both detail (60vh) and overview (50vh) heroes
|
||||||
|
// stay correctly framed across the phone breakpoint range.
|
||||||
|
const HERO_NARROW_FIT_WIDTH = 400;
|
||||||
|
const HERO_NARROW_FIT_HEIGHT = 480;
|
||||||
|
|
||||||
|
type HeroVariant = 'wide' | 'narrow';
|
||||||
|
|
||||||
|
const HERO_VARIANT_SPECS: ReadonlyArray<{
|
||||||
|
name: HeroVariant;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fitWidth: number;
|
||||||
|
fitHeight: number;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'wide',
|
||||||
|
width: HERO_WIDTH,
|
||||||
|
height: HERO_HEIGHT,
|
||||||
|
fitWidth: HERO_FIT_WIDTH,
|
||||||
|
fitHeight: HERO_FIT_HEIGHT
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'narrow',
|
||||||
|
width: HERO_NARROW_WIDTH,
|
||||||
|
height: HERO_NARROW_HEIGHT,
|
||||||
|
fitWidth: HERO_NARROW_FIT_WIDTH,
|
||||||
|
fitHeight: HERO_NARROW_FIT_HEIGHT
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
// SAC-tier polyline colours for the overview hero. Must stay in sync with
|
// 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
|
// the `SAC_COLOR` map in `HikesOverviewMap.svelte` so the static hero's
|
||||||
// trails look identical to the live ones.
|
// trails look identical to the live ones.
|
||||||
@@ -611,6 +651,29 @@ const OVERVIEW_MAX_ZOOM = 13;
|
|||||||
// renderer's output changes — e.g. stroke widths, palette tweaks.
|
// renderer's output changes — e.g. stroke widths, palette tweaks.
|
||||||
const OVERVIEW_RENDER_VERSION = 1;
|
const OVERVIEW_RENDER_VERSION = 1;
|
||||||
|
|
||||||
|
type OverviewVariantSpec = {
|
||||||
|
name: HeroVariant;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fitWidth: number;
|
||||||
|
fitHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Overview narrow uses the same canvas dims as the per-hike narrow but
|
||||||
|
// fits the union bbox at phone size — same `maxZoom: 13` clamp as the
|
||||||
|
// live map's `fitBounds`.
|
||||||
|
const OVERVIEW_VARIANT_SPECS: ReadonlyArray<OverviewVariantSpec> = [
|
||||||
|
{ name: 'wide', width: HERO_WIDTH, height: HERO_HEIGHT, fitWidth: OVERVIEW_FIT_WIDTH, fitHeight: OVERVIEW_FIT_HEIGHT },
|
||||||
|
{ name: 'narrow', width: HERO_NARROW_WIDTH, height: HERO_NARROW_HEIGHT, fitWidth: HERO_NARROW_FIT_WIDTH, fitHeight: HERO_NARROW_FIT_HEIGHT }
|
||||||
|
];
|
||||||
|
|
||||||
|
type OverviewVariantResult = {
|
||||||
|
url: string;
|
||||||
|
zoom: number;
|
||||||
|
center: [number, number];
|
||||||
|
outName: string;
|
||||||
|
};
|
||||||
|
|
||||||
async function processOverview(
|
async function processOverview(
|
||||||
hikes: HikeManifestEntry[]
|
hikes: HikeManifestEntry[]
|
||||||
): Promise<HikesOverview | undefined> {
|
): Promise<HikesOverview | undefined> {
|
||||||
@@ -637,13 +700,18 @@ async function processOverview(
|
|||||||
if (!Number.isFinite(minLat)) return undefined;
|
if (!Number.isFinite(minLat)) return undefined;
|
||||||
const bbox: [number, number, number, number] = [minLat, minLng, maxLat, maxLng];
|
const bbox: [number, number, number, number] = [minLat, minLng, maxLat, maxLng];
|
||||||
|
|
||||||
|
const slug = '_overview';
|
||||||
|
const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images');
|
||||||
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
|
async function renderVariant(spec: OverviewVariantSpec): Promise<OverviewVariantResult | undefined> {
|
||||||
const pose = computeStaticMapPose({
|
const pose = computeStaticMapPose({
|
||||||
bbox,
|
bbox,
|
||||||
width: HERO_WIDTH,
|
width: spec.width,
|
||||||
height: HERO_HEIGHT,
|
height: spec.height,
|
||||||
paddingPx: OVERVIEW_PADDING_PX,
|
paddingPx: OVERVIEW_PADDING_PX,
|
||||||
fitWidth: OVERVIEW_FIT_WIDTH,
|
fitWidth: spec.fitWidth,
|
||||||
fitHeight: OVERVIEW_FIT_HEIGHT,
|
fitHeight: spec.fitHeight,
|
||||||
maxZoom: OVERVIEW_MAX_ZOOM
|
maxZoom: OVERVIEW_MAX_ZOOM
|
||||||
});
|
});
|
||||||
if (!pose) return undefined;
|
if (!pose) return undefined;
|
||||||
@@ -653,8 +721,10 @@ async function processOverview(
|
|||||||
.update(
|
.update(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
bbox,
|
bbox,
|
||||||
w: HERO_WIDTH,
|
w: spec.width,
|
||||||
h: HERO_HEIGHT,
|
h: spec.height,
|
||||||
|
fw: spec.fitWidth,
|
||||||
|
fh: spec.fitHeight,
|
||||||
lines,
|
lines,
|
||||||
maxZoom: OVERVIEW_MAX_ZOOM,
|
maxZoom: OVERVIEW_MAX_ZOOM,
|
||||||
pad: OVERVIEW_PADDING_PX,
|
pad: OVERVIEW_PADDING_PX,
|
||||||
@@ -664,41 +734,58 @@ async function processOverview(
|
|||||||
.digest('hex')
|
.digest('hex')
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
|
|
||||||
// Slug "_overview" picks up the same vite dev-server image plugin and
|
// `wide` keeps the historical `overview.<hash>.webp` filename to
|
||||||
// nginx public-serve rules as per-hike assets, without colliding with
|
// preserve existing caches.
|
||||||
// any real hike slug (leading underscore is not a valid slug character).
|
const outName = spec.name === 'wide' ? `overview.${hash}.webp` : `overview-${spec.name}.${hash}.webp`;
|
||||||
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 outPath = path.join(outDir, outName);
|
||||||
|
|
||||||
const renderT0 = Date.now();
|
const renderT0 = Date.now();
|
||||||
console.log(
|
console.log(
|
||||||
`[build-hikes:_overview] ${lines.length} polylines · zoom ${pose.zoom} · ` +
|
`[build-hikes:_overview] ${spec.name}: ${lines.length} polylines · zoom ${pose.zoom} · ` +
|
||||||
`${Math.round(HERO_WIDTH / 256)}×${Math.round(HERO_HEIGHT / 256)} tile grid`
|
`${Math.round(spec.width / 256)}×${Math.round(spec.height / 256)} tile grid`
|
||||||
);
|
);
|
||||||
if (!(await pathExists(outPath))) {
|
if (!(await pathExists(outPath))) {
|
||||||
const ok = await renderOverviewMap({
|
const ok = await renderOverviewMap({
|
||||||
pose,
|
pose,
|
||||||
polylines: lines,
|
polylines: lines,
|
||||||
outputPath: outPath,
|
outputPath: outPath,
|
||||||
width: HERO_WIDTH,
|
width: spec.width,
|
||||||
height: HERO_HEIGHT
|
height: spec.height
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
console.warn(`[build-hikes:_overview] render failed — too few tiles fetched`);
|
console.warn(`[build-hikes:_overview] ${spec.name} render failed — too few tiles fetched`);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
console.log(`[build-hikes:_overview] rendered ${outName} in ${Date.now() - renderT0}ms`);
|
console.log(`[build-hikes:_overview] ${spec.name} rendered ${outName} in ${Date.now() - renderT0}ms`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[build-hikes:_overview] cached (${outName})`);
|
console.log(`[build-hikes:_overview] ${spec.name} cached (${outName})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sweep orphan overview heroes from previous builds.
|
return {
|
||||||
|
url: `/hikes/${slug}/images/${outName}`,
|
||||||
|
zoom: pose.zoom,
|
||||||
|
center: [pose.centerLat, pose.centerLng],
|
||||||
|
outName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(OVERVIEW_VARIANT_SPECS.map(renderVariant));
|
||||||
|
const byVariant: Partial<Record<HeroVariant, OverviewVariantResult>> = {};
|
||||||
|
for (let i = 0; i < OVERVIEW_VARIANT_SPECS.length; i++) {
|
||||||
|
const r = results[i];
|
||||||
|
if (r) byVariant[OVERVIEW_VARIANT_SPECS[i].name] = r;
|
||||||
|
}
|
||||||
|
if (!byVariant.wide) return undefined;
|
||||||
|
|
||||||
|
// Sweep orphan overview heroes from previous builds. Keep both wide
|
||||||
|
// and narrow outNames if present.
|
||||||
|
const keep = new Set<string>();
|
||||||
|
for (const r of Object.values(byVariant)) {
|
||||||
|
if (r) keep.add(r.outName);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const existing = await fs.readdir(outDir);
|
const existing = await fs.readdir(outDir);
|
||||||
const orphans = existing.filter((f) => f !== outName);
|
const orphans = existing.filter((f) => !keep.has(f));
|
||||||
if (orphans.length > 0) {
|
if (orphans.length > 0) {
|
||||||
await Promise.all(orphans.map((f) => fs.unlink(path.join(outDir, f)).catch(() => {})));
|
await Promise.all(orphans.map((f) => fs.unlink(path.join(outDir, f)).catch(() => {})));
|
||||||
console.log(`[build-hikes:_overview] removed ${orphans.length} orphaned file(s)`);
|
console.log(`[build-hikes:_overview] removed ${orphans.length} orphaned file(s)`);
|
||||||
@@ -708,28 +795,30 @@ async function processOverview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: `/hikes/${slug}/images/${outName}`,
|
url: byVariant.wide.url,
|
||||||
zoom: pose.zoom,
|
zoom: byVariant.wide.zoom,
|
||||||
center: [pose.centerLat, pose.centerLng]
|
center: byVariant.wide.center,
|
||||||
|
urlNarrow: byVariant.narrow?.url,
|
||||||
|
zoomNarrow: byVariant.narrow?.zoom,
|
||||||
|
centerNarrow: byVariant.narrow?.center
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processHero(
|
type HeroVariantResult = {
|
||||||
slug: string,
|
|
||||||
track: GpxPoint[],
|
|
||||||
bbox: [number, number, number, number],
|
|
||||||
imagePoints: ImagePoint[]
|
|
||||||
): Promise<
|
|
||||||
| {
|
|
||||||
lightUrl: string;
|
lightUrl: string;
|
||||||
lightOutName: string;
|
lightOutName: string;
|
||||||
darkUrl: string;
|
darkUrl: string;
|
||||||
darkOutName: string;
|
darkOutName: string;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
center: [number, number];
|
center: [number, number];
|
||||||
}
|
};
|
||||||
| undefined
|
|
||||||
> {
|
async function processHero(
|
||||||
|
slug: string,
|
||||||
|
track: GpxPoint[],
|
||||||
|
bbox: [number, number, number, number],
|
||||||
|
imagePoints: ImagePoint[]
|
||||||
|
): Promise<Partial<Record<HeroVariant, HeroVariantResult>> | undefined> {
|
||||||
if (track.length < 2) return undefined;
|
if (track.length < 2) return undefined;
|
||||||
|
|
||||||
const polyline: Array<[number, number]> = track.map((p) => [p.lat, p.lng]);
|
const polyline: Array<[number, number]> = track.map((p) => [p.lat, p.lng]);
|
||||||
@@ -740,28 +829,26 @@ async function processHero(
|
|||||||
.filter((ip) => ip.visibility !== 'private')
|
.filter((ip) => ip.visibility !== 'private')
|
||||||
.map((ip) => ({ lat: ip.lat, lng: ip.lng }));
|
.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');
|
const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images');
|
||||||
await fs.mkdir(outDir, { recursive: true });
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
// Per-theme hash + render. Theme is part of the hash so light and dark
|
// One pose per viewport variant — narrow uses a phone-sized fit so the
|
||||||
// produce distinct filenames; both variants regenerate whenever the
|
// chosen integer zoom matches what Leaflet picks at the same container
|
||||||
// route, photo set, or renderer version changes.
|
// size, eliminating the visible "the static is too zoomed in" mismatch
|
||||||
async function renderVariant(theme: 'light' | 'dark'): Promise<{ url: string; outName: string } | undefined> {
|
// the user sees with only a desktop-sized pose.
|
||||||
|
async function renderForViewport(
|
||||||
|
spec: (typeof HERO_VARIANT_SPECS)[number]
|
||||||
|
): Promise<HeroVariantResult | undefined> {
|
||||||
|
const pose = computeStaticMapPose({
|
||||||
|
bbox,
|
||||||
|
width: spec.width,
|
||||||
|
height: spec.height,
|
||||||
|
fitWidth: spec.fitWidth,
|
||||||
|
fitHeight: spec.fitHeight
|
||||||
|
});
|
||||||
|
if (!pose) return undefined;
|
||||||
|
|
||||||
|
async function renderTheme(theme: 'light' | 'dark'): Promise<{ url: string; outName: string } | undefined> {
|
||||||
const fillColor = theme === 'dark' ? HERO_BADGE_FILL_DARK : HERO_BADGE_FILL_LIGHT;
|
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 borderColor = theme === 'dark' ? HERO_BADGE_BORDER_DARK : HERO_BADGE_BORDER_LIGHT;
|
||||||
const iconColor = theme === 'dark' ? HERO_BADGE_ICON_DARK : HERO_BADGE_ICON_LIGHT;
|
const iconColor = theme === 'dark' ? HERO_BADGE_ICON_DARK : HERO_BADGE_ICON_LIGHT;
|
||||||
@@ -770,8 +857,10 @@ async function processHero(
|
|||||||
.update(
|
.update(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
bbox,
|
bbox,
|
||||||
w: HERO_WIDTH,
|
w: spec.width,
|
||||||
h: HERO_HEIGHT,
|
h: spec.height,
|
||||||
|
fw: spec.fitWidth,
|
||||||
|
fh: spec.fitHeight,
|
||||||
color: HERO_TRAIL_COLOR,
|
color: HERO_TRAIL_COLOR,
|
||||||
poly: polyline,
|
poly: polyline,
|
||||||
photos: photoMarkers,
|
photos: photoMarkers,
|
||||||
@@ -784,7 +873,10 @@ async function processHero(
|
|||||||
.digest('hex')
|
.digest('hex')
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
|
|
||||||
const outName = `hero-${theme}.${hash}.webp`;
|
// `wide` keeps the historical `hero-{theme}.<hash>.webp` filename
|
||||||
|
// so existing on-disk caches survive the variant split.
|
||||||
|
const prefix = spec.name === 'wide' ? `hero-${theme}` : `hero-${spec.name}-${theme}`;
|
||||||
|
const outName = `${prefix}.${hash}.webp`;
|
||||||
const outPath = path.join(outDir, outName);
|
const outPath = path.join(outDir, outName);
|
||||||
|
|
||||||
if (!(await pathExists(outPath))) {
|
if (!(await pathExists(outPath))) {
|
||||||
@@ -793,8 +885,8 @@ async function processHero(
|
|||||||
polyline,
|
polyline,
|
||||||
color: HERO_TRAIL_COLOR,
|
color: HERO_TRAIL_COLOR,
|
||||||
outputPath: outPath,
|
outputPath: outPath,
|
||||||
width: HERO_WIDTH,
|
width: spec.width,
|
||||||
height: HERO_HEIGHT,
|
height: spec.height,
|
||||||
photoMarkers,
|
photoMarkers,
|
||||||
photoMarkerColor: fillColor,
|
photoMarkerColor: fillColor,
|
||||||
photoMarkerBorderColor: borderColor,
|
photoMarkerBorderColor: borderColor,
|
||||||
@@ -806,7 +898,7 @@ async function processHero(
|
|||||||
return { url: `/hikes/${slug}/images/${outName}`, outName };
|
return { url: `/hikes/${slug}/images/${outName}`, outName };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [light, dark] = await Promise.all([renderVariant('light'), renderVariant('dark')]);
|
const [light, dark] = await Promise.all([renderTheme('light'), renderTheme('dark')]);
|
||||||
if (!light || !dark) return undefined;
|
if (!light || !dark) return undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -819,6 +911,18 @@ async function processHero(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const variants = await Promise.all(HERO_VARIANT_SPECS.map(renderForViewport));
|
||||||
|
const out: Partial<Record<HeroVariant, HeroVariantResult>> = {};
|
||||||
|
for (let i = 0; i < HERO_VARIANT_SPECS.length; i++) {
|
||||||
|
const v = variants[i];
|
||||||
|
if (v) out[HERO_VARIANT_SPECS[i].name] = v;
|
||||||
|
}
|
||||||
|
// At minimum we need the wide variant — that's what desktop falls back
|
||||||
|
// to, and CLS-reservation on the page expects it. Narrow is best-effort.
|
||||||
|
if (!out.wide) return undefined;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Image EXIF -> ImagePoint
|
// Image EXIF -> ImagePoint
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -985,8 +1089,11 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
]);
|
]);
|
||||||
if (iconResult) keepFiles.images.add(iconResult.outName);
|
if (iconResult) keepFiles.images.add(iconResult.outName);
|
||||||
if (heroResult) {
|
if (heroResult) {
|
||||||
keepFiles.images.add(heroResult.lightOutName);
|
for (const v of Object.values(heroResult)) {
|
||||||
keepFiles.images.add(heroResult.darkOutName);
|
if (!v) continue;
|
||||||
|
keepFiles.images.add(v.lightOutName);
|
||||||
|
keepFiles.images.add(v.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
|
||||||
@@ -1050,10 +1157,16 @@ 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 heroWide = heroResult?.wide;
|
||||||
const heroMapUrlDark = heroResult?.darkUrl;
|
const heroNarrow = heroResult?.narrow;
|
||||||
const heroMapZoom = heroResult?.zoom;
|
const heroMapUrlLight = heroWide?.lightUrl;
|
||||||
const heroMapCenter = heroResult?.center;
|
const heroMapUrlDark = heroWide?.darkUrl;
|
||||||
|
const heroMapZoom = heroWide?.zoom;
|
||||||
|
const heroMapCenter = heroWide?.center;
|
||||||
|
const heroMapUrlLightNarrow = heroNarrow?.lightUrl;
|
||||||
|
const heroMapUrlDarkNarrow = heroNarrow?.darkUrl;
|
||||||
|
const heroMapZoomNarrow = heroNarrow?.zoom;
|
||||||
|
const heroMapCenterNarrow = heroNarrow?.center;
|
||||||
|
|
||||||
const entry: HikeManifestEntry = {
|
const entry: HikeManifestEntry = {
|
||||||
slug,
|
slug,
|
||||||
@@ -1085,6 +1198,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
heroMapUrlDark,
|
heroMapUrlDark,
|
||||||
heroMapZoom,
|
heroMapZoom,
|
||||||
heroMapCenter,
|
heroMapCenter,
|
||||||
|
heroMapUrlLightNarrow,
|
||||||
|
heroMapUrlDarkNarrow,
|
||||||
|
heroMapZoomNarrow,
|
||||||
|
heroMapCenterNarrow,
|
||||||
imagePoints
|
imagePoints
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,32 @@
|
|||||||
// page's hero map.
|
// page's hero map.
|
||||||
let heroMapReady = $state(false);
|
let heroMapReady = $state(false);
|
||||||
|
|
||||||
|
// Phone vs. desktop viewport switch — drives which pre-rendered pose
|
||||||
|
// (`HIKES_OVERVIEW.zoom/center` vs. `.zoomNarrow/.centerNarrow`) we
|
||||||
|
// hand to Leaflet's first `setView` so it lands aligned with whichever
|
||||||
|
// static `<img>` the CSS is showing. Starts `false` to match SSR (which
|
||||||
|
// has no window); the $effect snaps it to the real value on mount and
|
||||||
|
// keeps it in sync if the user rotates / resizes across the breakpoint.
|
||||||
|
let narrowViewport = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const mq = window.matchMedia('(max-width: 560px)');
|
||||||
|
narrowViewport = mq.matches;
|
||||||
|
const onChange = (e: MediaQueryListEvent) => {
|
||||||
|
narrowViewport = e.matches;
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', onChange);
|
||||||
|
return () => mq.removeEventListener('change', onChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
const overviewPose = $derived.by(() => {
|
||||||
|
if (!HIKES_OVERVIEW) return null;
|
||||||
|
if (narrowViewport && HIKES_OVERVIEW.urlNarrow && HIKES_OVERVIEW.centerNarrow && typeof HIKES_OVERVIEW.zoomNarrow === 'number') {
|
||||||
|
return { center: HIKES_OVERVIEW.centerNarrow, zoom: HIKES_OVERVIEW.zoomNarrow };
|
||||||
|
}
|
||||||
|
return { center: HIKES_OVERVIEW.center, zoom: HIKES_OVERVIEW.zoom };
|
||||||
|
});
|
||||||
|
|
||||||
// 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 —
|
||||||
@@ -91,12 +117,12 @@
|
|||||||
visible hike's preview polyline, coloured by SAC tier.
|
visible hike's preview polyline, coloured by SAC tier.
|
||||||
Displayed at native pixel size (`object-fit: none`) so it
|
Displayed at native pixel size (`object-fit: none`) so it
|
||||||
overlays Leaflet's live tiles exactly. The image fades out
|
overlays Leaflet's live tiles exactly. The image fades out
|
||||||
once Leaflet's first tile batch loads. Unlike the detail
|
once Leaflet's first tile batch loads. Two width variants
|
||||||
hero, the overview map looks the same in light and dark
|
ship — desktop (wide pose) and phone (narrow pose, ≤560 CSS
|
||||||
mode (only the per-hike camera badges are theme-aware,
|
px). CSS chooses which one shows based on a media query so
|
||||||
and the overview has none) so a single variant ships. -->
|
hydration doesn't need to wait. -->
|
||||||
<img
|
<img
|
||||||
class="hero-static"
|
class="hero-static hero-static-wide"
|
||||||
class:faded={heroMapReady}
|
class:faded={heroMapReady}
|
||||||
src={HIKES_OVERVIEW.url}
|
src={HIKES_OVERVIEW.url}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -104,11 +130,22 @@
|
|||||||
loading="eager"
|
loading="eager"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
|
{#if HIKES_OVERVIEW.urlNarrow}
|
||||||
|
<img
|
||||||
|
class="hero-static hero-static-narrow"
|
||||||
|
class:faded={heroMapReady}
|
||||||
|
src={HIKES_OVERVIEW.urlNarrow}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<HikesOverviewMap
|
<HikesOverviewMap
|
||||||
hikes={visible}
|
hikes={visible}
|
||||||
initialCenter={HIKES_OVERVIEW?.center}
|
initialCenter={overviewPose?.center}
|
||||||
initialZoom={HIKES_OVERVIEW?.zoom}
|
initialZoom={overviewPose?.zoom}
|
||||||
onReady={() => (heroMapReady = true)}
|
onReady={() => (heroMapReady = true)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -198,6 +235,16 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wide ↔ narrow viewport swap. The narrow variant is rendered at a
|
||||||
|
* phone-sized fit, so the zoom matches what Leaflet picks at the same
|
||||||
|
* container width — without this the desktop hero would land too
|
||||||
|
* zoomed-in on phones (its pose was chosen for ~1920 CSS px). */
|
||||||
|
.hero-static-narrow { display: none; }
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.hero-static-wide { display: none; }
|
||||||
|
.hero-static-narrow { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Live overview map sits above the static; transparent so the static
|
/* Live overview map sits above the static; transparent so the static
|
||||||
* shows through until Leaflet's tile pane paints over it. */
|
* shows through until Leaflet's tile pane paints over it. */
|
||||||
.hero-map :global(.overview-map) {
|
.hero-map :global(.overview-map) {
|
||||||
|
|||||||
@@ -31,6 +31,38 @@
|
|||||||
// handover is a soft cross-fade rather than a swap.
|
// handover is a soft cross-fade rather than a swap.
|
||||||
let heroMapReady = $state(false);
|
let heroMapReady = $state(false);
|
||||||
|
|
||||||
|
// Phone vs. desktop viewport — picks which pre-rendered pose we hand
|
||||||
|
// to Leaflet's first `setView` so it lands aligned with the static
|
||||||
|
// `<img>` the CSS is showing. Starts `false` for SSR; the $effect snaps
|
||||||
|
// it to the real value on mount and keeps it in sync across rotate /
|
||||||
|
// resize. See `/hikes/+page.svelte` for the matching overview-side
|
||||||
|
// pattern.
|
||||||
|
let narrowViewport = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const mq = window.matchMedia('(max-width: 560px)');
|
||||||
|
narrowViewport = mq.matches;
|
||||||
|
const onChange = (e: MediaQueryListEvent) => {
|
||||||
|
narrowViewport = e.matches;
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', onChange);
|
||||||
|
return () => mq.removeEventListener('change', onChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
const heroPose = $derived.by(() => {
|
||||||
|
if (
|
||||||
|
narrowViewport &&
|
||||||
|
hike.heroMapCenterNarrow &&
|
||||||
|
typeof hike.heroMapZoomNarrow === 'number'
|
||||||
|
) {
|
||||||
|
return { center: hike.heroMapCenterNarrow, zoom: hike.heroMapZoomNarrow };
|
||||||
|
}
|
||||||
|
if (hike.heroMapCenter && typeof hike.heroMapZoom === 'number') {
|
||||||
|
return { center: hike.heroMapCenter, zoom: hike.heroMapZoom };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
fetch(hike.trackUrl)
|
fetch(hike.trackUrl)
|
||||||
@@ -233,15 +265,16 @@
|
|||||||
<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}
|
{#if hike.heroMapUrlLight}
|
||||||
<!-- Build-time static composite of Swisstopo tiles + the trail
|
<!-- Build-time static composite of Swisstopo tiles + the trail
|
||||||
polyline + public photo markers. Two variants ship (light
|
polyline + public photo markers. Four variants ship — theme
|
||||||
and dark); CSS picks the right one based on `data-theme`
|
(light/dark) × viewport (wide/narrow). Theme is picked by
|
||||||
with `prefers-color-scheme` as the fallback. Both are
|
`data-theme` / `prefers-color-scheme`; viewport by a
|
||||||
rendered at the same zoom + centre as the live Leaflet
|
`max-width: 560px` media query. Each variant is rendered at
|
||||||
map, and displayed at native pixel size (object-fit: none)
|
the same pose Leaflet's `fitBounds` picks for its target
|
||||||
so they overlay the live tiles exactly. The image fades
|
container size, so the static→live cross-fade aligns
|
||||||
out once Leaflet's first tile batch loads. -->
|
pixel-perfectly. The image fades out once Leaflet's first
|
||||||
|
tile batch loads. -->
|
||||||
<img
|
<img
|
||||||
class="hero-static hero-static-light"
|
class="hero-static hero-static-light hero-static-wide"
|
||||||
class:faded={heroMapReady}
|
class:faded={heroMapReady}
|
||||||
src={hike.heroMapUrlLight}
|
src={hike.heroMapUrlLight}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -252,7 +285,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if hike.heroMapUrlDark}
|
{#if hike.heroMapUrlDark}
|
||||||
<img
|
<img
|
||||||
class="hero-static hero-static-dark"
|
class="hero-static hero-static-dark hero-static-wide"
|
||||||
class:faded={heroMapReady}
|
class:faded={heroMapReady}
|
||||||
src={hike.heroMapUrlDark}
|
src={hike.heroMapUrlDark}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -261,18 +294,40 @@
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if hike.heroMapUrlLightNarrow}
|
||||||
|
<img
|
||||||
|
class="hero-static hero-static-light hero-static-narrow"
|
||||||
|
class:faded={heroMapReady}
|
||||||
|
src={hike.heroMapUrlLightNarrow}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if hike.heroMapUrlDarkNarrow}
|
||||||
|
<img
|
||||||
|
class="hero-static hero-static-dark hero-static-narrow"
|
||||||
|
class:faded={heroMapReady}
|
||||||
|
src={hike.heroMapUrlDarkNarrow}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if track && track.length > 0}
|
{#if track && track.length > 0}
|
||||||
<HikeMap
|
<HikeMap
|
||||||
{track}
|
{track}
|
||||||
imagePoints={visibleImagePoints}
|
imagePoints={visibleImagePoints}
|
||||||
showPrivate
|
showPrivate
|
||||||
initialCenter={hike.heroMapCenter}
|
initialCenter={heroPose?.center}
|
||||||
initialZoom={hike.heroMapZoom}
|
initialZoom={heroPose?.zoom}
|
||||||
onReady={() => (heroMapReady = true)}
|
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 if !hike.heroMapUrl}
|
{:else if !hike.heroMapUrlLight}
|
||||||
<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">
|
||||||
@@ -447,18 +502,46 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Theme-aware switch between the two pre-rendered variants. Default
|
/* 2×2 picker: theme (light/dark) × viewport (wide/narrow). Each `<img>`
|
||||||
* is light; `prefers-color-scheme: dark` flips it; an explicit
|
* has both qualifiers (e.g. `.hero-static-light.hero-static-wide`); we
|
||||||
* `data-theme` attribute on `<html>` always wins (higher specificity). */
|
* hide everything by default and reveal exactly one based on the
|
||||||
.hero-static-dark { display: none; }
|
* active theme and the `max-width: 560px` media query. The narrow
|
||||||
|
* variant uses a phone-sized pose so the auto-fit zoom matches what
|
||||||
|
* Leaflet picks at the same container width. */
|
||||||
|
.hero-static { display: none; }
|
||||||
|
|
||||||
|
/* Default (light theme assumed, no `data-theme` attribute, no
|
||||||
|
* `prefers-color-scheme: dark`): show the wide-light variant. */
|
||||||
|
.hero-static-light.hero-static-wide { display: block; }
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.hero-static-light.hero-static-wide { display: none; }
|
||||||
|
.hero-static-light.hero-static-narrow { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.hero-static-light { display: none; }
|
.hero-static-light.hero-static-wide,
|
||||||
.hero-static-dark { display: block; }
|
.hero-static-light.hero-static-narrow { display: none; }
|
||||||
|
.hero-static-dark.hero-static-wide { display: block; }
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.hero-static-dark.hero-static-wide { display: none; }
|
||||||
|
.hero-static-dark.hero-static-narrow { display: block; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Explicit `data-theme` always wins. */
|
||||||
|
:global(:root[data-theme='light']) .hero-static-dark { display: none !important; }
|
||||||
|
:global(:root[data-theme='light']) .hero-static-light.hero-static-wide { display: block; }
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
:global(:root[data-theme='light']) .hero-static-light.hero-static-wide { display: none; }
|
||||||
|
:global(:root[data-theme='light']) .hero-static-light.hero-static-narrow { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme='dark']) .hero-static-light { display: none !important; }
|
||||||
|
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-wide { display: block; }
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-wide { display: none; }
|
||||||
|
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-narrow { 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
|
||||||
|
|||||||
+24
-6
@@ -81,8 +81,7 @@ export type HikeManifestEntry = {
|
|||||||
* arrives. */
|
* arrives. */
|
||||||
heroMapUrlLight?: string;
|
heroMapUrlLight?: string;
|
||||||
/** Pre-rendered hero map (dark theme). Same pose as
|
/** Pre-rendered hero map (dark theme). Same pose as
|
||||||
* `heroMapUrlLight` but with the tile composite passed through an
|
* `heroMapUrlLight` but with theme-appropriate photo-marker colours. */
|
||||||
* `invert(1) hue-rotate(180deg)` filter so the map fits the dark UI. */
|
|
||||||
heroMapUrlDark?: string;
|
heroMapUrlDark?: string;
|
||||||
/** Zoom level the static hero was rendered at. Leaflet uses this with
|
/** Zoom level the static hero was rendered at. Leaflet uses this with
|
||||||
* `heroMapCenter` to land on the exact same view on first paint, so
|
* `heroMapCenter` to land on the exact same view on first paint, so
|
||||||
@@ -91,6 +90,17 @@ export type HikeManifestEntry = {
|
|||||||
/** Map centre `[lat, lng]` the static hero was rendered around. */
|
/** Map centre `[lat, lng]` the static hero was rendered around. */
|
||||||
heroMapCenter?: [number, number];
|
heroMapCenter?: [number, number];
|
||||||
|
|
||||||
|
/** Narrow-viewport variant of the pre-rendered hero (≤ 560 CSS px).
|
||||||
|
* Rendered with a phone-sized `fitWidth`/`fitHeight`, so the chosen
|
||||||
|
* integer zoom matches what Leaflet's `fitBounds` picks at the same
|
||||||
|
* container size. The page shows these instead of the wide variants
|
||||||
|
* on small screens, otherwise the wide hero would land too zoomed-in
|
||||||
|
* (its pose was chosen for a 1920×640 desktop hero). */
|
||||||
|
heroMapUrlLightNarrow?: string;
|
||||||
|
heroMapUrlDarkNarrow?: string;
|
||||||
|
heroMapZoomNarrow?: number;
|
||||||
|
heroMapCenterNarrow?: [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[];
|
||||||
};
|
};
|
||||||
@@ -100,11 +110,19 @@ export type HikeManifestEntry = {
|
|||||||
* shows it under the sticky nav until Leaflet's first tile batch loads,
|
* 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. */
|
* then fades it out — same handover pattern as the per-hike detail hero. */
|
||||||
export type HikesOverview = {
|
export type HikesOverview = {
|
||||||
/** Absolute URL of the pre-rendered WebP. */
|
/** Absolute URL of the wide (desktop) pre-rendered WebP. */
|
||||||
url: string;
|
url: string;
|
||||||
/** Integer zoom the static was rendered at (matches Leaflet's
|
/** Integer zoom the wide static was rendered at (matches Leaflet's
|
||||||
* `fitBounds(unionBounds, { padding: 32, maxZoom: 13 })` choice). */
|
* `fitBounds(unionBounds, { padding: 32, maxZoom: 13 })` choice on a
|
||||||
|
* desktop-sized container). */
|
||||||
zoom: number;
|
zoom: number;
|
||||||
/** Centre `[lat, lng]` the static was rendered around. */
|
/** Centre `[lat, lng]` the wide static was rendered around. */
|
||||||
center: [number, number];
|
center: [number, number];
|
||||||
|
/** Narrow-viewport variant for phones (≤ 560 CSS px). Rendered at a
|
||||||
|
* phone-sized `fitWidth`/`fitHeight`, so the chosen zoom matches what
|
||||||
|
* Leaflet picks at the same container size. The page shows it instead
|
||||||
|
* of `url` on small screens. */
|
||||||
|
urlNarrow?: string;
|
||||||
|
zoomNarrow?: number;
|
||||||
|
centerNarrow?: [number, number];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user