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.
Cards + filter bar fly up from below when arriving at /hikes and drop
back down when leaving (in both directions of /hikes ↔ detail). Clicked
card morphs into the detail hero with a cross-fade so the thumbnail
dissolves into the map instead of snapping. Photo strip slides in from
the right. Root content cross-fades so metrics + content under the hero
phase in rather than appear at the end of the morph.
Track JSON moves from a client-side $effect into +page.ts so the strip
is in the DOM at view-transition snapshot time — also kills the brief
layout shift when it used to pop in post-load.
Build-time image optimization plus auth-gated private content.
- <Image> (src/lib/components/Image.svelte): wraps @sveltejs/enhanced-img
for public images under src/lib/assets/images/ (AVIF/WebP, multiple
widths, lazy by default), plus a `private` mode for auth-gated images.
- Private images: scripts/build-private-images.ts encodes sources from
src/lib/assets/private-images/ into private-assets/ (outside the bundle)
and a manifest; served only via the auth-checked /private-images/
endpoint (X-Accel-Redirect in prod, disk read in dev).
- HikeImage gains a `src` prose mode: build-hikes encodes non-waypoint
images referenced in .svx and exposes them by filename (imagesByName);
a `private` attr routes them through the gated /hikes/<slug>/private/ path.
- <Private> (src/lib/components/Private.svelte): renders prose only to
logged-in viewers (cosmetic gating — text still ships in the bundle).
- deploy.sh rsyncs private-assets/; prod needs an nginx internal
/protected-images/ location.
The metrics flex row stretched each tile to the tall route-icon's height,
so the grid's auto rows spread apart and pushed the value and its label to
opposite halves. Center the items instead of stretching.
Add tile-proxy/: a small Rust (axum) service behind nginx that serves one
canonical XYZ scheme (/{karte,luftbild,dufour}/{z}/{x}/{y}) and, per tile,
picks the provider by geometry — swisstopo when the tile overlaps a
swisstopo-covered region (Switzerland or Liechtenstein, each simplified +
2 km buffer; tile-bbox ∩ polygon at every zoom), otherwise OpenTopoMap
(schematic) / Esri World Imagery (satellite), with an auto-fallback for
border 404s. Includes the region generator (gen-regions.mjs), a Makefile,
nginx caching-proxy + systemd examples, and a README. Listen address is
env-driven (TILE_PROXY_ADDR).
App side:
- New mapTiles.ts is the single source for the proxy URLs + combined
attribution; HikeMap / HikesOverviewMap / EditMap fetch through
maps.bocken.org instead of swisstopo directly, on-map attribution
controls removed, preconnect + footer credits updated (swisstopo /
OpenStreetMap+OpenTopoMap / Esri).
- Region-aware schematic max zoom (isSwissRegion helper): detail map caps
at z17 abroad and hides the CH/LI-only Dufour layer; overview caps at
z18 when a shown hike is abroad.
- Route-builder: add the satellite layer via the same bottom-right layer
popover as the other maps.
Represent a multi-day hike as separate named GPX <trk> elements, one per
stage, while still treating the whole thing as one route on the overview.
GPX & build:
- gpx.ts: parseGpxStages (one stage per <trk>) + multi-track buildGpx.
- build-hikes.ts: per-stage stats with totals summed across stages so the
overnight gaps (distance, time) and the altitude jump between stages are
excluded; previewBreaks recorded where stages sit >1 km apart.
- types: HikeStage, manifest `stages?` and `previewBreaks?` (both optional —
single-stage hikes are unchanged).
Detail page:
- HikeStageNav: a light itinerary-stepper switcher (numbered nodes, active
glows in the accent) writing a shared stageStore.
- Selecting a stage scopes the metrics, elevation profile (x-window),
map (highlight + zoom, dim the rest) and photo strip/markers; "Alle
Etappen" shows the whole route.
Overview: live map and the prerendered static composite both break the
preview line across >1 km inter-stage transfers (previewBreaks).
Route builder:
- Mark any placed waypoint as a stage start (named) from the waypoint list
or the detail panel; export assembles each stage independently into its
own <trk>; import re-marks stage boundaries from a multi-track GPX.
Map interaction:
- Overview map: widen the canvas renderer hit-test (tolerance) so a route
can be hovered/clicked from a comfortable margin instead of demanding a
pixel-perfect click on the thin line.
- Detail map: drive the elevation cursor from a whole-map mousemove that
snaps to the nearest track point within ~70 px (track cached in
layer-point space, refreshed on zoom/move), instead of requiring the
pointer to ride exactly on the trail. The hover pin now renders for
map-sourced hovers too, and is recoloured to nord red as a distinct
"you are here" marker. Trail polyline made non-interactive.
Detail page:
- Move the photo strip above the stats row and trim it (3:2 cards).
- Add a fullscreen lightbox: an expand button on each card opens the
full-res image with prev/next, arrow keys, Esc, backdrop-close and a
body-scroll lock; opening/stepping syncs the map + strip. The card's
existing click (map-position sync) is preserved.
- Cap inline prose images at 680 px (centered) so they don't blow up to
full width in the single-column layout on wider screens; the desktop
two-column layout is unaffected.
Live and static heroes on the detail page were rendered in Nord red,
while the same trail on the /hikes overview map already used the
high-contrast SAC palette (orange/red/blue). The mismatch made the
detail trail look muted against the Pixelkarte; the overview's choice
also doubles as a navigational hint ("the orange trail you saw on
the map is this one").
Introduce $lib/data/sacColors as the single source of truth so
HikeMap, HikesOverviewMap, and the build-side static renderer all
pull the same palette. Bump HERO_RENDER_VERSION to v6 so stale
Nord-red static heroes get re-rendered on the next build.
Detail-page tag chips become anchor links to `/hikes?tag=<name>`.
HikesFilterBar grows a tags fieldset (sorted by frequency, with the
hash prefix the chips use) so the user can keep narrowing from there.
Multi-tag filtering is OR — a hike matching any selected tag stays
visible. AND would shrink the listing fast given how few tags most
hikes carry; OR matches what "show me more like this" feels like.
The overview page reads `tag` query params on mount and pre-fills the
filter — supports repeated params (`?tag=winter&tag=easy`).
Tag chips slot between the metric tiles and the elevation profile —
muted pills with a hash prefix so they read as framing context, not
another row of glance-info competing with the numbers.
The standalone "GPX herunterladen" pill (centred, primary-styled,
above the photo strip) moves into a small footer at the bottom of
the article, grouped with waypoint count, publish date, and the
required swisstopo attribution. GPX is a power-user export, not a
CTA — placing it with other ancillary metadata matches its weight.
26 public-domain coats of arms fetched once from Wikimedia Commons
via scripts/download-cantons.ts and committed under static/cantons/.
$lib/data/cantons.ts maps Swisstopo's free-form name (German default,
French/Italian alternates for Romandie / Ticino) to the ISO code +
emblem URL.
Card shows an 18×22 emblem, detail page a 24×30 one — both with a
drop-shadow so they read against the dark hero gradient. Unknown
canton names fall back to plain text without the emblem.
The downloaded SVGs are written verbatim — earlier draft prepended a
provenance HTML comment but that breaks the leading `<?xml … ?>` and
browsers refuse to render the image. Provenance lives in the script's
CANTONS table instead.
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`.
Each hike now ships two SSR-friendly hero images (light + dark theme),
composited at build time from Swisstopo tiles plus an SVG overlay of the
trail polyline, start/end markers, and per-photo camera badges. The
detail page renders the right variant immediately at first paint, then
hands over to live Leaflet without visible jumps.
Renderer (scripts/staticHikeMap.ts):
- Parallel tile fetcher with on-disk cache (scripts/.cache/swisstopo-
tiles/) for re-build idempotency.
- `computeStaticMapPose` picks the zoom + centre Leaflet's fitBounds
would land on at a reference 1920x640 viewport, so the static frames
the full route on every typical desktop hero.
- Canvas rendered at 3840x2400 — large enough to fully cover ultrawide
/ 4K displays at native pixel size, so `object-fit: none` keeps the
trail pixel-aligned with Leaflet's tile pane.
- SVG overlay: trail in Nord red, start dot Nord green, end dot Nord
red, Lucide `camera` icon inside each photo badge. Photo badge
fill / border / icon-stroke colours are passed per theme so light and
dark variants match the live `.hike-photo-marker .badge` styling
exactly (Nord10/Nord8 fill, Nord6/Nord1 border, white/Nord0 icon
stroke). Map tiles themselves are identical across themes — no naive
invert (it mangles the Pixelkarte palette).
- Public photo markers only — private positions are filtered out so
they don't leak in the SSR image.
Build wiring (scripts/build-hikes.ts):
- `processHero` renders both variants in parallel, hashes inputs per
theme, skips on cache hit. Output filenames carry the content hash so
changes invalidate cleanly via the existing orphan sweep.
- `HikeManifestEntry` gains `heroMapUrlLight`, `heroMapUrlDark`,
`heroMapZoom`, `heroMapCenter`.
Detail page (src/routes/hikes/[slug]/+page.svelte):
- Reserves the hero box height up front (kills CLS).
- Renders both `<img>` tags; CSS picks the right one via `data-theme`
with `prefers-color-scheme` as the fallback.
- `object-fit: none; object-position: center` so the image displays at
native pixel size, perfectly aligned with Leaflet's tile rendering.
- `isolation: isolate` on the hero gives Leaflet's z-index:200+ panes
a stacking context so they can't bleed over the sticky nav.
HikeMap (src/lib/components/hikes/HikeMap.svelte):
- New `initialCenter` / `initialZoom` props — when set, the map opens
with `setView` at the static hero's pose instead of `fitBounds`.
- New `onReady` callback — fires after the post-fly-to-bounds tile
batch finishes loading (or a 350 ms safety timeout), letting the
detail page fade the static out onto fully-painted tiles instead of
onto a brief grey gap.
- Sequence: render static -> Leaflet `setView` to match -> first tile
load -> `flyToBounds(track)` to the natural fit -> wait for new
tiles -> fade static out.
Build pipeline (scripts/build-hikes.ts) parses per-hike GPX, encodes
images via sharp, reverse-geocodes the centroid against Swisstopo and
emits a typed manifest under src/lib/data/hikes.generated.ts (gitignored).
Track JSON + image binaries live outside /static; served in dev by a
small hike-images plugin in vite.config.ts, in prod by nginx (private/
images proxied through Node + X-Accel-Redirect for auth-gating).
/hikes overview: full-bleed Swisstopo hero map (HikesOverviewMap) sits
under the sticky nav, drawing one polyline per route coloured by SAC
tier (T1 yellow Wegweiser, T2/T3 white-red-white, T4-T6 white-blue-
white). Click navigates, hover thickens + tooltips. Layer toggle,
recenter, GPS controls mirror the detail map (minus images toggle).
Cards drop the trail SVG, gain a per-route icon + SAC marker
pictogram on the cover, altitude range, season label, and "Neu" badge
for recently-published hikes. Filter bar + totals strip recompute over
the currently-visible set.
/hikes/[slug]: hero map with elevation profile, photo strip with map
sync, scroll-position pin, GPX download, SAC marker stats + min/max
altitude + season.
Route-builder (/hikes/route-builder): client-side draft persisted to
localStorage, EXIF-driven image placement, snap-to-route via BRouter
(OSRM + linear fallback) and Swisstopo profile.json elevation
enrichment that handles degenerate same-coord segments via the height
endpoint.
Filter init switched from a script-time snapshot of data.hikes (which
sporadically returned a one-hike subset during dev hydration and
locked the page to that single hike) to a post-mount \$effect.
Content under src/content/hikes/ intentionally not included (WIP).