feat(hikes): medium hero variant + Switzerland-framed overview, drop static→live wobble
Three related improvements to the pre-rendered hero map system: * New medium viewport variant (561–899 CSS px) for the per-hike detail hero and the /hikes overview. Tablet/split-pane viewports were getting the wide pose (chosen for ~1920 CSS px), which landed too zoomed in. Each variant is rendered at a pose matching its container, so the static→Leaflet handover aligns at every band. Manifest fields are optional — pages fall back to the wide variant on tablets until build-hikes regenerates the images. * Overview frames on Switzerland (fixed center [46.82, 8.23]) with explicit per-variant zooms (wide=8, medium=8, narrow=7) rather than auto-fitting the union of hike bboxes. The previous behavior zoomed in on whichever corner the catalogue clustered in; this reads as "hikes across CH". Bumps OVERVIEW_RENDER_VERSION so cached overview images get invalidated on the next build. * Removed the post-tile-load flyToBounds in both HikeMap.svelte and HikesOverviewMap.svelte. The map already opens at the static pose via setView; the second auto-fit was adding a visible wobble on routes whose bbox sits at an integer-zoom boundary (e.g. the Einsiedeln–Unteriberg detail), where the build-time fit and Leaflet's runtime fit disagree by one zoom step at the user's actual container size.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.90.0",
|
"version": "1.91.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+67
-49
@@ -29,7 +29,7 @@ import {
|
|||||||
type GpxStage
|
type GpxStage
|
||||||
} 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, renderOverviewMap, renderStaticMap } from './staticHikeMap.js';
|
import { computeStaticMapPose, lngLatToPx, renderOverviewMap, renderStaticMap } from './staticHikeMap.js';
|
||||||
import { sacTrailColor, SAC_TRAIL_COLOR } from '../src/lib/data/sacColors.js';
|
import { sacTrailColor, SAC_TRAIL_COLOR } from '../src/lib/data/sacColors.js';
|
||||||
import { computeElevationStats, computeElevationRange } from '../src/lib/hikes/elevation.js';
|
import { computeElevationStats, computeElevationRange } from '../src/lib/hikes/elevation.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -692,7 +692,17 @@ const HERO_NARROW_HEIGHT = 1200;
|
|||||||
const HERO_NARROW_FIT_WIDTH = 400;
|
const HERO_NARROW_FIT_WIDTH = 400;
|
||||||
const HERO_NARROW_FIT_HEIGHT = 480;
|
const HERO_NARROW_FIT_HEIGHT = 480;
|
||||||
|
|
||||||
type HeroVariant = 'wide' | 'narrow';
|
// Medium-viewport variant for the 561–899 px CSS width band (tablets, split
|
||||||
|
// panes, small laptops). Picks an in-between pose so the auto-fit zoom
|
||||||
|
// matches what Leaflet computes at tablet widths — without this the wide
|
||||||
|
// hero (chosen for ~1920 CSS px) lands too zoomed-in on tablets, and the
|
||||||
|
// narrow hero (chosen for ~400 CSS px) lands too zoomed-out.
|
||||||
|
const HERO_MEDIUM_WIDTH = 2400;
|
||||||
|
const HERO_MEDIUM_HEIGHT = 1500;
|
||||||
|
const HERO_MEDIUM_FIT_WIDTH = 1000;
|
||||||
|
const HERO_MEDIUM_FIT_HEIGHT = 500;
|
||||||
|
|
||||||
|
type HeroVariant = 'wide' | 'medium' | 'narrow';
|
||||||
|
|
||||||
const HERO_VARIANT_SPECS: ReadonlyArray<{
|
const HERO_VARIANT_SPECS: ReadonlyArray<{
|
||||||
name: HeroVariant;
|
name: HeroVariant;
|
||||||
@@ -708,6 +718,13 @@ const HERO_VARIANT_SPECS: ReadonlyArray<{
|
|||||||
fitWidth: HERO_FIT_WIDTH,
|
fitWidth: HERO_FIT_WIDTH,
|
||||||
fitHeight: HERO_FIT_HEIGHT
|
fitHeight: HERO_FIT_HEIGHT
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'medium',
|
||||||
|
width: HERO_MEDIUM_WIDTH,
|
||||||
|
height: HERO_MEDIUM_HEIGHT,
|
||||||
|
fitWidth: HERO_MEDIUM_FIT_WIDTH,
|
||||||
|
fitHeight: HERO_MEDIUM_FIT_HEIGHT
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'narrow',
|
name: 'narrow',
|
||||||
width: HERO_NARROW_WIDTH,
|
width: HERO_NARROW_WIDTH,
|
||||||
@@ -717,32 +734,23 @@ const HERO_VARIANT_SPECS: ReadonlyArray<{
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Padding + max-zoom match the live overview map's
|
// Bump when the overview renderer's output changes — e.g. stroke widths,
|
||||||
// `fitBounds(..., { padding: [32, 32], maxZoom: 13 })` so the static lands
|
// palette tweaks, framing constants in `processOverview`.
|
||||||
// at the same pose Leaflet will fit to. fitHeight matches the page's
|
const OVERVIEW_RENDER_VERSION = 2;
|
||||||
// `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;
|
|
||||||
|
|
||||||
type OverviewVariantSpec = {
|
type OverviewVariantSpec = {
|
||||||
name: HeroVariant;
|
name: HeroVariant;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
fitWidth: number;
|
|
||||||
fitHeight: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Overview narrow uses the same canvas dims as the per-hike narrow but
|
// One spec per viewport band; the overview's per-variant zoom is fixed in
|
||||||
// fits the union bbox at phone size — same `maxZoom: 13` clamp as the
|
// `processOverview` rather than being derived from a fit bbox, so we only
|
||||||
// live map's `fitBounds`.
|
// need to know the output canvas size here.
|
||||||
const OVERVIEW_VARIANT_SPECS: ReadonlyArray<OverviewVariantSpec> = [
|
const OVERVIEW_VARIANT_SPECS: ReadonlyArray<OverviewVariantSpec> = [
|
||||||
{ name: 'wide', width: HERO_WIDTH, height: HERO_HEIGHT, fitWidth: OVERVIEW_FIT_WIDTH, fitHeight: OVERVIEW_FIT_HEIGHT },
|
{ name: 'wide', width: HERO_WIDTH, height: HERO_HEIGHT },
|
||||||
{ name: 'narrow', width: HERO_NARROW_WIDTH, height: HERO_NARROW_HEIGHT, fitWidth: HERO_NARROW_FIT_WIDTH, fitHeight: HERO_NARROW_FIT_HEIGHT }
|
{ name: 'medium', width: HERO_MEDIUM_WIDTH, height: HERO_MEDIUM_HEIGHT },
|
||||||
|
{ name: 'narrow', width: HERO_NARROW_WIDTH, height: HERO_NARROW_HEIGHT }
|
||||||
];
|
];
|
||||||
|
|
||||||
type OverviewVariantResult = {
|
type OverviewVariantResult = {
|
||||||
@@ -764,49 +772,47 @@ async function processOverview(
|
|||||||
}));
|
}));
|
||||||
if (lines.length === 0) return undefined;
|
if (lines.length === 0) return undefined;
|
||||||
|
|
||||||
// Union bbox over every hike's bbox — that's what Leaflet's
|
// Frame on Switzerland, not the union of hike bboxes — the overview
|
||||||
// `fitBounds(bounds)` operates on with `extend()` per polyline. Using
|
// reads as "hikes across CH" instead of zooming in on whichever corner
|
||||||
// each hike's bbox rather than every polyline point keeps the math
|
// the catalogue clusters in. Center is the country's approximate
|
||||||
// cheap without losing the framing accuracy.
|
// geographic midpoint; zoom is picked per-variant so CH fills the
|
||||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
// hero at each viewport without the country bbox having to fit
|
||||||
for (const h of hikes) {
|
// pixel-perfectly inside `OVERVIEW_FIT_*` (which would force the wide
|
||||||
const [a, b, c, d] = h.bbox;
|
// variant down to z=7 — too far out). At these zooms the rendered
|
||||||
if (a < minLat) minLat = a;
|
// canvas slightly overflows the visible hero on the short axis, and
|
||||||
if (c > maxLat) maxLat = c;
|
// `object-fit: none` crops to the centre — exactly what we want for
|
||||||
if (b < minLng) minLng = b;
|
// a "frame on the country" composition.
|
||||||
if (d > maxLng) maxLng = d;
|
const CH_CENTER: [number, number] = [46.82, 8.23];
|
||||||
}
|
const OVERVIEW_ZOOM_BY_VARIANT: Record<HeroVariant, number> = {
|
||||||
if (!Number.isFinite(minLat)) return undefined;
|
wide: 8,
|
||||||
const bbox: [number, number, number, number] = [minLat, minLng, maxLat, maxLng];
|
medium: 8,
|
||||||
|
narrow: 7
|
||||||
|
};
|
||||||
|
|
||||||
const slug = '_overview';
|
const slug = '_overview';
|
||||||
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 });
|
||||||
|
|
||||||
async function renderVariant(spec: OverviewVariantSpec): Promise<OverviewVariantResult | undefined> {
|
async function renderVariant(spec: OverviewVariantSpec): Promise<OverviewVariantResult | undefined> {
|
||||||
const pose = computeStaticMapPose({
|
const zoom = OVERVIEW_ZOOM_BY_VARIANT[spec.name];
|
||||||
bbox,
|
const c = lngLatToPx(CH_CENTER[1], CH_CENTER[0], zoom);
|
||||||
width: spec.width,
|
const pose = {
|
||||||
height: spec.height,
|
zoom,
|
||||||
paddingPx: OVERVIEW_PADDING_PX,
|
centerLat: CH_CENTER[0],
|
||||||
fitWidth: spec.fitWidth,
|
centerLng: CH_CENTER[1],
|
||||||
fitHeight: spec.fitHeight,
|
originX: Math.round(c.x - spec.width / 2),
|
||||||
maxZoom: OVERVIEW_MAX_ZOOM
|
originY: Math.round(c.y - spec.height / 2)
|
||||||
});
|
};
|
||||||
if (!pose) return undefined;
|
|
||||||
|
|
||||||
const hash = crypto
|
const hash = crypto
|
||||||
.createHash('sha256')
|
.createHash('sha256')
|
||||||
.update(
|
.update(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
bbox,
|
center: CH_CENTER,
|
||||||
|
zoom,
|
||||||
w: spec.width,
|
w: spec.width,
|
||||||
h: spec.height,
|
h: spec.height,
|
||||||
fw: spec.fitWidth,
|
|
||||||
fh: spec.fitHeight,
|
|
||||||
lines,
|
lines,
|
||||||
maxZoom: OVERVIEW_MAX_ZOOM,
|
|
||||||
pad: OVERVIEW_PADDING_PX,
|
|
||||||
v: OVERVIEW_RENDER_VERSION
|
v: OVERVIEW_RENDER_VERSION
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -877,6 +883,9 @@ async function processOverview(
|
|||||||
url: byVariant.wide.url,
|
url: byVariant.wide.url,
|
||||||
zoom: byVariant.wide.zoom,
|
zoom: byVariant.wide.zoom,
|
||||||
center: byVariant.wide.center,
|
center: byVariant.wide.center,
|
||||||
|
urlMedium: byVariant.medium?.url,
|
||||||
|
zoomMedium: byVariant.medium?.zoom,
|
||||||
|
centerMedium: byVariant.medium?.center,
|
||||||
urlNarrow: byVariant.narrow?.url,
|
urlNarrow: byVariant.narrow?.url,
|
||||||
zoomNarrow: byVariant.narrow?.zoom,
|
zoomNarrow: byVariant.narrow?.zoom,
|
||||||
centerNarrow: byVariant.narrow?.center
|
centerNarrow: byVariant.narrow?.center
|
||||||
@@ -1388,11 +1397,16 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
|
|
||||||
const iconUrl = iconResult?.url;
|
const iconUrl = iconResult?.url;
|
||||||
const heroWide = heroResult?.wide;
|
const heroWide = heroResult?.wide;
|
||||||
|
const heroMedium = heroResult?.medium;
|
||||||
const heroNarrow = heroResult?.narrow;
|
const heroNarrow = heroResult?.narrow;
|
||||||
const heroMapUrlLight = heroWide?.lightUrl;
|
const heroMapUrlLight = heroWide?.lightUrl;
|
||||||
const heroMapUrlDark = heroWide?.darkUrl;
|
const heroMapUrlDark = heroWide?.darkUrl;
|
||||||
const heroMapZoom = heroWide?.zoom;
|
const heroMapZoom = heroWide?.zoom;
|
||||||
const heroMapCenter = heroWide?.center;
|
const heroMapCenter = heroWide?.center;
|
||||||
|
const heroMapUrlLightMedium = heroMedium?.lightUrl;
|
||||||
|
const heroMapUrlDarkMedium = heroMedium?.darkUrl;
|
||||||
|
const heroMapZoomMedium = heroMedium?.zoom;
|
||||||
|
const heroMapCenterMedium = heroMedium?.center;
|
||||||
const heroMapUrlLightNarrow = heroNarrow?.lightUrl;
|
const heroMapUrlLightNarrow = heroNarrow?.lightUrl;
|
||||||
const heroMapUrlDarkNarrow = heroNarrow?.darkUrl;
|
const heroMapUrlDarkNarrow = heroNarrow?.darkUrl;
|
||||||
const heroMapZoomNarrow = heroNarrow?.zoom;
|
const heroMapZoomNarrow = heroNarrow?.zoom;
|
||||||
@@ -1431,6 +1445,10 @@ async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifes
|
|||||||
heroMapUrlDark,
|
heroMapUrlDark,
|
||||||
heroMapZoom,
|
heroMapZoom,
|
||||||
heroMapCenter,
|
heroMapCenter,
|
||||||
|
heroMapUrlLightMedium,
|
||||||
|
heroMapUrlDarkMedium,
|
||||||
|
heroMapZoomMedium,
|
||||||
|
heroMapCenterMedium,
|
||||||
heroMapUrlLightNarrow,
|
heroMapUrlLightNarrow,
|
||||||
heroMapUrlDarkNarrow,
|
heroMapUrlDarkNarrow,
|
||||||
heroMapZoomNarrow,
|
heroMapZoomNarrow,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function tileUrl(sub: string, layer: string, z: number, x: number, y: number): s
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Web Mercator: lng/lat → absolute pixel coordinate at a given zoom. */
|
/** Web Mercator: lng/lat → absolute pixel coordinate at a given zoom. */
|
||||||
function lngLatToPx(lng: number, lat: number, zoom: number): { x: number; y: number } {
|
export function lngLatToPx(lng: number, lat: number, zoom: number): { x: number; y: number } {
|
||||||
const n = 2 ** zoom;
|
const n = 2 ** zoom;
|
||||||
const x = ((lng + 180) / 360) * n * TILE_SIZE;
|
const x = ((lng + 180) / 360) * n * TILE_SIZE;
|
||||||
const latRad = (lat * Math.PI) / 180;
|
const latRad = (lat * Math.PI) / 180;
|
||||||
|
|||||||
@@ -191,50 +191,17 @@
|
|||||||
let currentBase: BaseLayer = 'schematic';
|
let currentBase: BaseLayer = 'schematic';
|
||||||
|
|
||||||
// First-paint handover: when the schematic tile layer finishes
|
// First-paint handover: when the schematic tile layer finishes
|
||||||
// loading its initial batch, fire `onReady` (so the static hero
|
// loading its initial batch, fire `onReady` so the static hero
|
||||||
// can fade out) AND — if we opened with `setView` to match a
|
// can fade out. The map already opened at the static pose via
|
||||||
// pre-rendered hero — animate to Leaflet's natural `fitBounds`
|
// `setView(initialCenter, initialZoom)` below, so the live
|
||||||
// view, which is typically slightly more zoomed out. The fade
|
// tiles paint over the static at the same framing — no second
|
||||||
// (~450 ms) overlaps with the zoom animation (~700 ms) so the
|
// animation is needed (and a `flyToBounds` here would actually
|
||||||
// user sees the map ease into its proper framing as the static
|
// cause a visible wobble on hikes whose bbox sits right at an
|
||||||
// dissolves.
|
// integer-zoom boundary, where the static's fit and Leaflet's
|
||||||
|
// runtime fit disagree by one zoom step at the user's actual
|
||||||
|
// container size).
|
||||||
tileLayers.schematic.once('load', () => {
|
tileLayers.schematic.once('load', () => {
|
||||||
// Initial tiles at the static-hero pose are present. If we
|
|
||||||
// don't need to fly to a different framing, fire `onReady`
|
|
||||||
// straight away — that's the simple path.
|
|
||||||
if (!initialCenter || typeof initialZoom !== 'number') {
|
|
||||||
onReady?.();
|
onReady?.();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise: start the fly-to-bounds animation while the
|
|
||||||
// static stays visible on top. Once the fly settles AND the
|
|
||||||
// new tiles for the final view are in (or a short safety
|
|
||||||
// window elapses), THEN fire `onReady` so the static fades
|
|
||||||
// out over fully-loaded tiles — no grey flash in the gap
|
|
||||||
// between fly-end and tile-load.
|
|
||||||
map.flyToBounds(initialBounds, {
|
|
||||||
padding: [24, 24],
|
|
||||||
duration: 0.9,
|
|
||||||
easeLinearity: 0.3
|
|
||||||
});
|
|
||||||
map.once('moveend', () => {
|
|
||||||
let fired = false;
|
|
||||||
const fire = () => {
|
|
||||||
if (fired) return;
|
|
||||||
fired = true;
|
|
||||||
onReady?.();
|
|
||||||
};
|
|
||||||
// `load` fires when every currently-visible tile has
|
|
||||||
// arrived. Usually that's the trigger.
|
|
||||||
tileLayers.schematic.once('load', fire);
|
|
||||||
// Safety net: if the fly didn't change zoom enough to
|
|
||||||
// require any new tiles, `load` may not re-fire. 350 ms
|
|
||||||
// is short enough to feel responsive, long enough for
|
|
||||||
// the post-fly tile batch to arrive on typical
|
|
||||||
// connections.
|
|
||||||
setTimeout(fire, 350);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Canvas-rendered polylines can't resolve CSS custom properties,
|
// Canvas-rendered polylines can't resolve CSS custom properties,
|
||||||
|
|||||||
@@ -151,35 +151,17 @@
|
|||||||
// union bounds.
|
// union bounds.
|
||||||
let initialBounds: ReturnType<typeof L.latLngBounds> | null = null;
|
let initialBounds: ReturnType<typeof L.latLngBounds> | null = null;
|
||||||
|
|
||||||
// First-paint handover: when the schematic tile layer finishes
|
// First-paint handover: fire `onReady` once the schematic tile
|
||||||
// loading its initial batch, fire `onReady` (so the static hero
|
// layer's initial batch loads so the static hero can fade out.
|
||||||
// can fade out) and — if we opened with `setView` to match a
|
// The map already opened at the static pose via setView (see
|
||||||
// pre-rendered hero — animate to Leaflet's natural `fitBounds`
|
// the initialCenter branch below), so no extra animation is
|
||||||
// of the union polyline bounds. The fade overlaps with the zoom
|
// needed — and `flyToBounds(union)` here used to cause a
|
||||||
// animation so the user sees the map ease into its final
|
// visible wobble on hikes whose union bbox sits at an integer-
|
||||||
// framing as the static dissolves. Mirrors the same pattern in
|
// zoom boundary, where the static's fit and Leaflet's runtime
|
||||||
|
// fit disagree by one zoom step. Mirrors the same fix in
|
||||||
// `HikeMap.svelte`.
|
// `HikeMap.svelte`.
|
||||||
tileLayers.schematic.once('load', () => {
|
tileLayers.schematic.once('load', () => {
|
||||||
if (!initialCenter || typeof initialZoom !== 'number' || !initialBounds) {
|
|
||||||
onReady?.();
|
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-
|
||||||
|
|||||||
@@ -28,22 +28,30 @@
|
|||||||
// 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
|
// Three-band viewport switch — drives which pre-rendered pose
|
||||||
// (`HIKES_OVERVIEW.zoom/center` vs. `.zoomNarrow/.centerNarrow`) we
|
// (`HIKES_OVERVIEW.zoom/center` vs. `.zoomMedium/.centerMedium` vs.
|
||||||
// hand to Leaflet's first `setView` so it lands aligned with whichever
|
// `.zoomNarrow/.centerNarrow`) we hand to Leaflet's first `setView` so
|
||||||
// static `<img>` the CSS is showing. Starts `false` to match SSR (which
|
// it lands aligned with whichever static `<img>` the CSS is showing.
|
||||||
// has no window); the $effect snaps it to the real value on mount and
|
// Starts `false`/`false` to match SSR (which has no window); the
|
||||||
// keeps it in sync if the user rotates / resizes across the breakpoint.
|
// $effect snaps to the real value on mount and keeps both flags in
|
||||||
|
// sync across rotate/resize. `narrow` wins over `medium` when both
|
||||||
|
// would match (≤560 is also <900).
|
||||||
let narrowViewport = $state(false);
|
let narrowViewport = $state(false);
|
||||||
|
let mediumViewport = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const mq = window.matchMedia('(max-width: 560px)');
|
const mqNarrow = window.matchMedia('(max-width: 560px)');
|
||||||
narrowViewport = mq.matches;
|
const mqMedium = window.matchMedia('(min-width: 561px) and (max-width: 899px)');
|
||||||
const onChange = (e: MediaQueryListEvent) => {
|
narrowViewport = mqNarrow.matches;
|
||||||
narrowViewport = e.matches;
|
mediumViewport = mqMedium.matches;
|
||||||
|
const onNarrow = (e: MediaQueryListEvent) => { narrowViewport = e.matches; };
|
||||||
|
const onMedium = (e: MediaQueryListEvent) => { mediumViewport = e.matches; };
|
||||||
|
mqNarrow.addEventListener('change', onNarrow);
|
||||||
|
mqMedium.addEventListener('change', onMedium);
|
||||||
|
return () => {
|
||||||
|
mqNarrow.removeEventListener('change', onNarrow);
|
||||||
|
mqMedium.removeEventListener('change', onMedium);
|
||||||
};
|
};
|
||||||
mq.addEventListener('change', onChange);
|
|
||||||
return () => mq.removeEventListener('change', onChange);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const overviewPose = $derived.by(() => {
|
const overviewPose = $derived.by(() => {
|
||||||
@@ -51,6 +59,9 @@
|
|||||||
if (narrowViewport && HIKES_OVERVIEW.urlNarrow && HIKES_OVERVIEW.centerNarrow && typeof HIKES_OVERVIEW.zoomNarrow === 'number') {
|
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.centerNarrow, zoom: HIKES_OVERVIEW.zoomNarrow };
|
||||||
}
|
}
|
||||||
|
if (mediumViewport && HIKES_OVERVIEW.urlMedium && HIKES_OVERVIEW.centerMedium && typeof HIKES_OVERVIEW.zoomMedium === 'number') {
|
||||||
|
return { center: HIKES_OVERVIEW.centerMedium, zoom: HIKES_OVERVIEW.zoomMedium };
|
||||||
|
}
|
||||||
return { center: HIKES_OVERVIEW.center, zoom: HIKES_OVERVIEW.zoom };
|
return { center: HIKES_OVERVIEW.center, zoom: HIKES_OVERVIEW.zoom };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,9 +204,9 @@
|
|||||||
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. Two width variants
|
once Leaflet's first tile batch loads. Three width variants
|
||||||
ship — desktop (wide pose) and phone (narrow pose, ≤560 CSS
|
ship — wide (≥900 CSS px), medium (561–899), narrow (≤560).
|
||||||
px). CSS chooses which one shows based on a media query so
|
CSS chooses which one shows based on a media query so
|
||||||
hydration doesn't need to wait. -->
|
hydration doesn't need to wait. -->
|
||||||
<img
|
<img
|
||||||
class="hero-static hero-static-wide"
|
class="hero-static hero-static-wide"
|
||||||
@@ -206,6 +217,17 @@
|
|||||||
loading="eager"
|
loading="eager"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
|
{#if HIKES_OVERVIEW.urlMedium}
|
||||||
|
<img
|
||||||
|
class="hero-static hero-static-medium"
|
||||||
|
class:faded={heroMapReady}
|
||||||
|
src={HIKES_OVERVIEW.urlMedium}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if HIKES_OVERVIEW.urlNarrow}
|
{#if HIKES_OVERVIEW.urlNarrow}
|
||||||
<img
|
<img
|
||||||
class="hero-static hero-static-narrow"
|
class="hero-static hero-static-narrow"
|
||||||
@@ -325,13 +347,20 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wide ↔ narrow viewport swap. The narrow variant is rendered at a
|
/* Three-band viewport swap (wide ≥900, medium 561–899, narrow ≤560).
|
||||||
* phone-sized fit, so the zoom matches what Leaflet picks at the same
|
* Each variant is rendered at a fit matching its band so Leaflet picks
|
||||||
* container width — without this the desktop hero would land too
|
* the same zoom on first paint — without this the desktop hero would
|
||||||
* zoomed-in on phones (its pose was chosen for ~1920 CSS px). */
|
* land too zoomed-in on tablets/phones (its pose was chosen for
|
||||||
|
* ~1920 CSS px), and the narrow hero would land too zoomed-out on
|
||||||
|
* tablets (chosen for ~400 CSS px). */
|
||||||
|
.hero-static-medium,
|
||||||
.hero-static-narrow { display: none; }
|
.hero-static-narrow { display: none; }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 899px) {
|
||||||
.hero-static-wide { display: none; }
|
.hero-static-wide { display: none; }
|
||||||
|
.hero-static-medium { display: block; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.hero-static-medium { display: none; }
|
||||||
.hero-static-narrow { display: block; }
|
.hero-static-narrow { display: block; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,22 +40,29 @@
|
|||||||
// 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
|
// Three-band viewport switch (narrow ≤560, medium 561–899, wide ≥900)
|
||||||
// to Leaflet's first `setView` so it lands aligned with the static
|
// — picks which pre-rendered pose we hand to Leaflet's first `setView`
|
||||||
// `<img>` the CSS is showing. Starts `false` for SSR; the $effect snaps
|
// so it lands aligned with the static `<img>` the CSS is showing.
|
||||||
// it to the real value on mount and keeps it in sync across rotate /
|
// Starts `false`/`false` for SSR; the $effect snaps to real values on
|
||||||
// resize. See `/hikes/+page.svelte` for the matching overview-side
|
// mount and keeps both flags in sync across rotate/resize. `narrow`
|
||||||
// pattern.
|
// wins over `medium` when both would match. See the matching overview
|
||||||
|
// pattern in `/hikes/+page.svelte`.
|
||||||
let narrowViewport = $state(false);
|
let narrowViewport = $state(false);
|
||||||
|
let mediumViewport = $state(false);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const mq = window.matchMedia('(max-width: 560px)');
|
const mqNarrow = window.matchMedia('(max-width: 560px)');
|
||||||
narrowViewport = mq.matches;
|
const mqMedium = window.matchMedia('(min-width: 561px) and (max-width: 899px)');
|
||||||
const onChange = (e: MediaQueryListEvent) => {
|
narrowViewport = mqNarrow.matches;
|
||||||
narrowViewport = e.matches;
|
mediumViewport = mqMedium.matches;
|
||||||
|
const onNarrow = (e: MediaQueryListEvent) => { narrowViewport = e.matches; };
|
||||||
|
const onMedium = (e: MediaQueryListEvent) => { mediumViewport = e.matches; };
|
||||||
|
mqNarrow.addEventListener('change', onNarrow);
|
||||||
|
mqMedium.addEventListener('change', onMedium);
|
||||||
|
return () => {
|
||||||
|
mqNarrow.removeEventListener('change', onNarrow);
|
||||||
|
mqMedium.removeEventListener('change', onMedium);
|
||||||
};
|
};
|
||||||
mq.addEventListener('change', onChange);
|
|
||||||
return () => mq.removeEventListener('change', onChange);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const canton = $derived(resolveCanton(hike.canton));
|
const canton = $derived(resolveCanton(hike.canton));
|
||||||
@@ -84,6 +91,13 @@
|
|||||||
) {
|
) {
|
||||||
return { center: hike.heroMapCenterNarrow, zoom: hike.heroMapZoomNarrow };
|
return { center: hike.heroMapCenterNarrow, zoom: hike.heroMapZoomNarrow };
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
mediumViewport &&
|
||||||
|
hike.heroMapCenterMedium &&
|
||||||
|
typeof hike.heroMapZoomMedium === 'number'
|
||||||
|
) {
|
||||||
|
return { center: hike.heroMapCenterMedium, zoom: hike.heroMapZoomMedium };
|
||||||
|
}
|
||||||
if (hike.heroMapCenter && typeof hike.heroMapZoom === 'number') {
|
if (hike.heroMapCenter && typeof hike.heroMapZoom === 'number') {
|
||||||
return { center: hike.heroMapCenter, zoom: hike.heroMapZoom };
|
return { center: hike.heroMapCenter, zoom: hike.heroMapZoom };
|
||||||
}
|
}
|
||||||
@@ -284,14 +298,14 @@
|
|||||||
<section class="hero-map" style="view-transition-name: hike-{hike.slug}; view-transition-class: hike-fly-in">
|
<section class="hero-map" style="view-transition-name: hike-{hike.slug}; view-transition-class: hike-fly-in">
|
||||||
{#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. Four variants ship — theme
|
polyline + public photo markers. Six variants ship — theme
|
||||||
(light/dark) × viewport (wide/narrow). Theme is picked by
|
(light/dark) × viewport (wide ≥900 / medium 561–899 /
|
||||||
`data-theme` / `prefers-color-scheme`; viewport by a
|
narrow ≤560 CSS px). Theme is picked by `data-theme` /
|
||||||
`max-width: 560px` media query. Each variant is rendered at
|
`prefers-color-scheme`; viewport by media queries. Each
|
||||||
the same pose Leaflet's `fitBounds` picks for its target
|
variant is rendered at the same pose Leaflet's `fitBounds`
|
||||||
container size, so the static→live cross-fade aligns
|
picks for its target container size, so the static→live
|
||||||
pixel-perfectly. The image fades out once Leaflet's first
|
cross-fade aligns pixel-perfectly. The image fades out once
|
||||||
tile batch loads. -->
|
Leaflet's first tile batch loads. -->
|
||||||
<img
|
<img
|
||||||
class="hero-static hero-static-light hero-static-wide"
|
class="hero-static hero-static-light hero-static-wide"
|
||||||
class:faded={heroMapReady}
|
class:faded={heroMapReady}
|
||||||
@@ -313,6 +327,28 @@
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if hike.heroMapUrlLightMedium}
|
||||||
|
<img
|
||||||
|
class="hero-static hero-static-light hero-static-medium"
|
||||||
|
class:faded={heroMapReady}
|
||||||
|
src={hike.heroMapUrlLightMedium}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if hike.heroMapUrlDarkMedium}
|
||||||
|
<img
|
||||||
|
class="hero-static hero-static-dark hero-static-medium"
|
||||||
|
class:faded={heroMapReady}
|
||||||
|
src={hike.heroMapUrlDarkMedium}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if hike.heroMapUrlLightNarrow}
|
{#if hike.heroMapUrlLightNarrow}
|
||||||
<img
|
<img
|
||||||
class="hero-static hero-static-light hero-static-narrow"
|
class="hero-static hero-static-light hero-static-narrow"
|
||||||
@@ -573,28 +609,37 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2×2 picker: theme (light/dark) × viewport (wide/narrow). Each `<img>`
|
/* 2×3 picker: theme (light/dark) × viewport (wide ≥900 / medium
|
||||||
* has both qualifiers (e.g. `.hero-static-light.hero-static-wide`); we
|
* 561–899 / narrow ≤560). Each `<img>` carries both qualifiers (e.g.
|
||||||
* hide everything by default and reveal exactly one based on the
|
* `.hero-static-light.hero-static-wide`); we hide everything by
|
||||||
* active theme and the `max-width: 560px` media query. The narrow
|
* default and reveal exactly one based on the active theme and the
|
||||||
* variant uses a phone-sized pose so the auto-fit zoom matches what
|
* viewport media queries. Each variant is rendered at a fit matching
|
||||||
* Leaflet picks at the same container width. */
|
* its band so Leaflet picks the same integer zoom on first paint. */
|
||||||
.hero-static { display: none; }
|
.hero-static { display: none; }
|
||||||
|
|
||||||
/* Default (light theme assumed, no `data-theme` attribute, no
|
/* Default (light theme assumed): show the wide-light, then step down
|
||||||
* `prefers-color-scheme: dark`): show the wide-light variant. */
|
* the cascade as viewports shrink. */
|
||||||
.hero-static-light.hero-static-wide { display: block; }
|
.hero-static-light.hero-static-wide { display: block; }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 899px) {
|
||||||
.hero-static-light.hero-static-wide { display: none; }
|
.hero-static-light.hero-static-wide { display: none; }
|
||||||
|
.hero-static-light.hero-static-medium { display: block; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.hero-static-light.hero-static-medium { display: none; }
|
||||||
.hero-static-light.hero-static-narrow { display: block; }
|
.hero-static-light.hero-static-narrow { display: block; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.hero-static-light.hero-static-wide,
|
.hero-static-light.hero-static-wide,
|
||||||
|
.hero-static-light.hero-static-medium,
|
||||||
.hero-static-light.hero-static-narrow { display: none; }
|
.hero-static-light.hero-static-narrow { display: none; }
|
||||||
.hero-static-dark.hero-static-wide { display: block; }
|
.hero-static-dark.hero-static-wide { display: block; }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 899px) {
|
||||||
.hero-static-dark.hero-static-wide { display: none; }
|
.hero-static-dark.hero-static-wide { display: none; }
|
||||||
|
.hero-static-dark.hero-static-medium { display: block; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.hero-static-dark.hero-static-medium { display: none; }
|
||||||
.hero-static-dark.hero-static-narrow { display: block; }
|
.hero-static-dark.hero-static-narrow { display: block; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,15 +647,23 @@
|
|||||||
/* Explicit `data-theme` always wins. */
|
/* Explicit `data-theme` always wins. */
|
||||||
:global(:root[data-theme='light']) .hero-static-dark { display: none !important; }
|
:global(:root[data-theme='light']) .hero-static-dark { display: none !important; }
|
||||||
:global(:root[data-theme='light']) .hero-static-light.hero-static-wide { display: block; }
|
:global(:root[data-theme='light']) .hero-static-light.hero-static-wide { display: block; }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 899px) {
|
||||||
:global(:root[data-theme='light']) .hero-static-light.hero-static-wide { display: none; }
|
:global(:root[data-theme='light']) .hero-static-light.hero-static-wide { display: none; }
|
||||||
|
:global(:root[data-theme='light']) .hero-static-light.hero-static-medium { display: block; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
:global(:root[data-theme='light']) .hero-static-light.hero-static-medium { display: none; }
|
||||||
:global(:root[data-theme='light']) .hero-static-light.hero-static-narrow { display: block; }
|
: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-light { display: none !important; }
|
||||||
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-wide { display: block; }
|
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-wide { display: block; }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 899px) {
|
||||||
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-wide { display: none; }
|
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-wide { display: none; }
|
||||||
|
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-medium { display: block; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-medium { display: none; }
|
||||||
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-narrow { display: block; }
|
:global(:root[data-theme='dark']) .hero-static-dark.hero-static-narrow { display: block; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,15 @@ 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];
|
||||||
|
|
||||||
|
/** Medium-viewport variant (561–899 CSS px — tablets, split panes,
|
||||||
|
* small laptops). Pose picked for a tablet-sized container so the
|
||||||
|
* static→Leaflet handover lands at the same integer zoom without the
|
||||||
|
* "too zoomed in" mismatch the wide pose would produce at this width. */
|
||||||
|
heroMapUrlLightMedium?: string;
|
||||||
|
heroMapUrlDarkMedium?: string;
|
||||||
|
heroMapZoomMedium?: number;
|
||||||
|
heroMapCenterMedium?: [number, number];
|
||||||
|
|
||||||
/** Narrow-viewport variant of the pre-rendered hero (≤ 560 CSS px).
|
/** Narrow-viewport variant of the pre-rendered hero (≤ 560 CSS px).
|
||||||
* Rendered with a phone-sized `fitWidth`/`fitHeight`, so the chosen
|
* Rendered with a phone-sized `fitWidth`/`fitHeight`, so the chosen
|
||||||
* integer zoom matches what Leaflet's `fitBounds` picks at the same
|
* integer zoom matches what Leaflet's `fitBounds` picks at the same
|
||||||
@@ -162,6 +171,12 @@ export type HikesOverview = {
|
|||||||
zoom: number;
|
zoom: number;
|
||||||
/** Centre `[lat, lng]` the wide static was rendered around. */
|
/** Centre `[lat, lng]` the wide static was rendered around. */
|
||||||
center: [number, number];
|
center: [number, number];
|
||||||
|
/** Medium-viewport variant (561–899 CSS px). Pose picked for a tablet-
|
||||||
|
* sized container so the static→Leaflet handover doesn't shift the map
|
||||||
|
* at this width. */
|
||||||
|
urlMedium?: string;
|
||||||
|
zoomMedium?: number;
|
||||||
|
centerMedium?: [number, number];
|
||||||
/** Narrow-viewport variant for phones (≤ 560 CSS px). Rendered at a
|
/** Narrow-viewport variant for phones (≤ 560 CSS px). Rendered at a
|
||||||
* phone-sized `fitWidth`/`fitHeight`, so the chosen zoom matches what
|
* phone-sized `fitWidth`/`fitHeight`, so the chosen zoom matches what
|
||||||
* Leaflet picks at the same container size. The page shows it instead
|
* Leaflet picks at the same container size. The page shows it instead
|
||||||
|
|||||||
Reference in New Issue
Block a user