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",
|
||||
"version": "1.73.0",
|
||||
"version": "1.74.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+257
-140
@@ -587,6 +587,46 @@ const HERO_BADGE_ICON_DARK = '#2e3440';
|
||||
// existing files get re-rendered on the next build.
|
||||
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
|
||||
// the `SAC_COLOR` map in `HikesOverviewMap.svelte` so the static hero's
|
||||
// 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.
|
||||
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(
|
||||
hikes: HikeManifestEntry[]
|
||||
): Promise<HikesOverview | undefined> {
|
||||
@@ -637,68 +700,92 @@ async function processOverview(
|
||||
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
|
||||
async function renderVariant(spec: OverviewVariantSpec): Promise<OverviewVariantResult | undefined> {
|
||||
const pose = computeStaticMapPose({
|
||||
bbox,
|
||||
width: spec.width,
|
||||
height: spec.height,
|
||||
paddingPx: OVERVIEW_PADDING_PX,
|
||||
fitWidth: spec.fitWidth,
|
||||
fitHeight: spec.fitHeight,
|
||||
maxZoom: OVERVIEW_MAX_ZOOM
|
||||
});
|
||||
if (!ok) {
|
||||
console.warn(`[build-hikes:_overview] render failed — too few tiles fetched`);
|
||||
return undefined;
|
||||
if (!pose) return undefined;
|
||||
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
bbox,
|
||||
w: spec.width,
|
||||
h: spec.height,
|
||||
fw: spec.fitWidth,
|
||||
fh: spec.fitHeight,
|
||||
lines,
|
||||
maxZoom: OVERVIEW_MAX_ZOOM,
|
||||
pad: OVERVIEW_PADDING_PX,
|
||||
v: OVERVIEW_RENDER_VERSION
|
||||
})
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, 8);
|
||||
|
||||
// `wide` keeps the historical `overview.<hash>.webp` filename to
|
||||
// preserve existing caches.
|
||||
const outName = spec.name === 'wide' ? `overview.${hash}.webp` : `overview-${spec.name}.${hash}.webp`;
|
||||
const outPath = path.join(outDir, outName);
|
||||
|
||||
const renderT0 = Date.now();
|
||||
console.log(
|
||||
`[build-hikes:_overview] ${spec.name}: ${lines.length} polylines · zoom ${pose.zoom} · ` +
|
||||
`${Math.round(spec.width / 256)}×${Math.round(spec.height / 256)} tile grid`
|
||||
);
|
||||
if (!(await pathExists(outPath))) {
|
||||
const ok = await renderOverviewMap({
|
||||
pose,
|
||||
polylines: lines,
|
||||
outputPath: outPath,
|
||||
width: spec.width,
|
||||
height: spec.height
|
||||
});
|
||||
if (!ok) {
|
||||
console.warn(`[build-hikes:_overview] ${spec.name} render failed — too few tiles fetched`);
|
||||
return undefined;
|
||||
}
|
||||
console.log(`[build-hikes:_overview] ${spec.name} rendered ${outName} in ${Date.now() - renderT0}ms`);
|
||||
} else {
|
||||
console.log(`[build-hikes:_overview] ${spec.name} cached (${outName})`);
|
||||
}
|
||||
console.log(`[build-hikes:_overview] rendered ${outName} in ${Date.now() - renderT0}ms`);
|
||||
} else {
|
||||
console.log(`[build-hikes:_overview] cached (${outName})`);
|
||||
|
||||
return {
|
||||
url: `/hikes/${slug}/images/${outName}`,
|
||||
zoom: pose.zoom,
|
||||
center: [pose.centerLat, pose.centerLng],
|
||||
outName
|
||||
};
|
||||
}
|
||||
|
||||
// Sweep orphan overview heroes from previous builds.
|
||||
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 {
|
||||
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) {
|
||||
await Promise.all(orphans.map((f) => fs.unlink(path.join(outDir, f)).catch(() => {})));
|
||||
console.log(`[build-hikes:_overview] removed ${orphans.length} orphaned file(s)`);
|
||||
@@ -708,28 +795,30 @@ async function processOverview(
|
||||
}
|
||||
|
||||
return {
|
||||
url: `/hikes/${slug}/images/${outName}`,
|
||||
zoom: pose.zoom,
|
||||
center: [pose.centerLat, pose.centerLng]
|
||||
url: byVariant.wide.url,
|
||||
zoom: byVariant.wide.zoom,
|
||||
center: byVariant.wide.center,
|
||||
urlNarrow: byVariant.narrow?.url,
|
||||
zoomNarrow: byVariant.narrow?.zoom,
|
||||
centerNarrow: byVariant.narrow?.center
|
||||
};
|
||||
}
|
||||
|
||||
type HeroVariantResult = {
|
||||
lightUrl: string;
|
||||
lightOutName: string;
|
||||
darkUrl: string;
|
||||
darkOutName: string;
|
||||
zoom: number;
|
||||
center: [number, number];
|
||||
};
|
||||
|
||||
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
|
||||
> {
|
||||
): Promise<Partial<Record<HeroVariant, HeroVariantResult>> | undefined> {
|
||||
if (track.length < 2) return undefined;
|
||||
|
||||
const polyline: Array<[number, number]> = track.map((p) => [p.lat, p.lng]);
|
||||
@@ -740,83 +829,98 @@ async function processHero(
|
||||
.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,
|
||||
// One pose per viewport variant — narrow uses a phone-sized fit so the
|
||||
// chosen integer zoom matches what Leaflet picks at the same container
|
||||
// size, eliminating the visible "the static is too zoomed in" mismatch
|
||||
// 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 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: spec.width,
|
||||
h: spec.height,
|
||||
fw: spec.fitWidth,
|
||||
fh: spec.fitHeight,
|
||||
color: HERO_TRAIL_COLOR,
|
||||
poly: polyline,
|
||||
photos: photoMarkers,
|
||||
fill: fillColor,
|
||||
border: borderColor,
|
||||
icon: iconColor,
|
||||
v: HERO_RENDER_VERSION
|
||||
})
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, 8);
|
||||
|
||||
// `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);
|
||||
|
||||
if (!(await pathExists(outPath))) {
|
||||
const ok = await renderStaticMap({
|
||||
pose,
|
||||
polyline,
|
||||
color: HERO_TRAIL_COLOR,
|
||||
poly: polyline,
|
||||
photos: photoMarkers,
|
||||
fill: fillColor,
|
||||
border: borderColor,
|
||||
icon: iconColor,
|
||||
v: HERO_RENDER_VERSION
|
||||
})
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, 8);
|
||||
outputPath: outPath,
|
||||
width: spec.width,
|
||||
height: spec.height,
|
||||
photoMarkers,
|
||||
photoMarkerColor: fillColor,
|
||||
photoMarkerBorderColor: borderColor,
|
||||
photoMarkerIconColor: iconColor
|
||||
});
|
||||
if (!ok) return undefined;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
return { url: `/hikes/${slug}/images/${outName}`, outName };
|
||||
const [light, dark] = await Promise.all([renderTheme('light'), renderTheme('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]
|
||||
};
|
||||
}
|
||||
|
||||
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]
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -985,8 +1089,11 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
||||
]);
|
||||
if (iconResult) keepFiles.images.add(iconResult.outName);
|
||||
if (heroResult) {
|
||||
keepFiles.images.add(heroResult.lightOutName);
|
||||
keepFiles.images.add(heroResult.darkOutName);
|
||||
for (const v of Object.values(heroResult)) {
|
||||
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
|
||||
@@ -1050,10 +1157,16 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
||||
const tags = Array.isArray(fm.tags) ? fm.tags : [];
|
||||
|
||||
const iconUrl = iconResult?.url;
|
||||
const heroMapUrlLight = heroResult?.lightUrl;
|
||||
const heroMapUrlDark = heroResult?.darkUrl;
|
||||
const heroMapZoom = heroResult?.zoom;
|
||||
const heroMapCenter = heroResult?.center;
|
||||
const heroWide = heroResult?.wide;
|
||||
const heroNarrow = heroResult?.narrow;
|
||||
const heroMapUrlLight = heroWide?.lightUrl;
|
||||
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 = {
|
||||
slug,
|
||||
@@ -1085,6 +1198,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
||||
heroMapUrlDark,
|
||||
heroMapZoom,
|
||||
heroMapCenter,
|
||||
heroMapUrlLightNarrow,
|
||||
heroMapUrlDarkNarrow,
|
||||
heroMapZoomNarrow,
|
||||
heroMapCenterNarrow,
|
||||
imagePoints
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,32 @@
|
||||
// page's hero map.
|
||||
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
|
||||
// hydration pass) shows every hike. `$effect` below clamps them down
|
||||
// to the actual data maxes once `data.hikes` is fully populated —
|
||||
@@ -91,12 +117,12 @@
|
||||
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. -->
|
||||
once Leaflet's first tile batch loads. Two width variants
|
||||
ship — desktop (wide pose) and phone (narrow pose, ≤560 CSS
|
||||
px). CSS chooses which one shows based on a media query so
|
||||
hydration doesn't need to wait. -->
|
||||
<img
|
||||
class="hero-static"
|
||||
class="hero-static hero-static-wide"
|
||||
class:faded={heroMapReady}
|
||||
src={HIKES_OVERVIEW.url}
|
||||
alt=""
|
||||
@@ -104,11 +130,22 @@
|
||||
loading="eager"
|
||||
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}
|
||||
<HikesOverviewMap
|
||||
hikes={visible}
|
||||
initialCenter={HIKES_OVERVIEW?.center}
|
||||
initialZoom={HIKES_OVERVIEW?.zoom}
|
||||
initialCenter={overviewPose?.center}
|
||||
initialZoom={overviewPose?.zoom}
|
||||
onReady={() => (heroMapReady = true)}
|
||||
/>
|
||||
</section>
|
||||
@@ -198,6 +235,16 @@
|
||||
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
|
||||
* shows through until Leaflet's tile pane paints over it. */
|
||||
.hero-map :global(.overview-map) {
|
||||
|
||||
@@ -31,6 +31,38 @@
|
||||
// handover is a soft cross-fade rather than a swap.
|
||||
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(() => {
|
||||
let aborted = false;
|
||||
fetch(hike.trackUrl)
|
||||
@@ -233,15 +265,16 @@
|
||||
<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. -->
|
||||
polyline + public photo markers. Four variants ship — theme
|
||||
(light/dark) × viewport (wide/narrow). Theme is picked by
|
||||
`data-theme` / `prefers-color-scheme`; viewport by a
|
||||
`max-width: 560px` media query. Each variant is rendered at
|
||||
the same pose Leaflet's `fitBounds` picks for its target
|
||||
container size, so the static→live cross-fade aligns
|
||||
pixel-perfectly. The image fades out once Leaflet's first
|
||||
tile batch loads. -->
|
||||
<img
|
||||
class="hero-static hero-static-light"
|
||||
class="hero-static hero-static-light hero-static-wide"
|
||||
class:faded={heroMapReady}
|
||||
src={hike.heroMapUrlLight}
|
||||
alt=""
|
||||
@@ -252,7 +285,7 @@
|
||||
{/if}
|
||||
{#if hike.heroMapUrlDark}
|
||||
<img
|
||||
class="hero-static hero-static-dark"
|
||||
class="hero-static hero-static-dark hero-static-wide"
|
||||
class:faded={heroMapReady}
|
||||
src={hike.heroMapUrlDark}
|
||||
alt=""
|
||||
@@ -261,18 +294,40 @@
|
||||
decoding="async"
|
||||
/>
|
||||
{/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}
|
||||
<HikeMap
|
||||
{track}
|
||||
imagePoints={visibleImagePoints}
|
||||
showPrivate
|
||||
initialCenter={hike.heroMapCenter}
|
||||
initialZoom={hike.heroMapZoom}
|
||||
initialCenter={heroPose?.center}
|
||||
initialZoom={heroPose?.zoom}
|
||||
onReady={() => (heroMapReady = true)}
|
||||
/>
|
||||
{:else if trackError}
|
||||
<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>
|
||||
{/if}
|
||||
<div class="hero-title">
|
||||
@@ -447,18 +502,46 @@
|
||||
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; }
|
||||
/* 2×2 picker: theme (light/dark) × viewport (wide/narrow). Each `<img>`
|
||||
* has both qualifiers (e.g. `.hero-static-light.hero-static-wide`); we
|
||||
* hide everything by default and reveal exactly one based on the
|
||||
* 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) {
|
||||
.hero-static-light { display: none; }
|
||||
.hero-static-dark { display: block; }
|
||||
.hero-static-light.hero-static-wide,
|
||||
.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
|
||||
|
||||
+24
-6
@@ -81,8 +81,7 @@ export type HikeManifestEntry = {
|
||||
* 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. */
|
||||
* `heroMapUrlLight` but with theme-appropriate photo-marker colours. */
|
||||
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
|
||||
@@ -91,6 +90,17 @@ export type HikeManifestEntry = {
|
||||
/** Map centre `[lat, lng]` the static hero was rendered around. */
|
||||
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:
|
||||
imagePoints: ImagePoint[];
|
||||
};
|
||||
@@ -100,11 +110,19 @@ export type HikeManifestEntry = {
|
||||
* 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. */
|
||||
/** Absolute URL of the wide (desktop) pre-rendered WebP. */
|
||||
url: string;
|
||||
/** Integer zoom the static was rendered at (matches Leaflet's
|
||||
* `fitBounds(unionBounds, { padding: 32, maxZoom: 13 })` choice). */
|
||||
/** Integer zoom the wide static was rendered at (matches Leaflet's
|
||||
* `fitBounds(unionBounds, { padding: 32, maxZoom: 13 })` choice on a
|
||||
* desktop-sized container). */
|
||||
zoom: number;
|
||||
/** Centre `[lat, lng]` the static was rendered around. */
|
||||
/** Centre `[lat, lng]` the wide static was rendered around. */
|
||||
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