feat(hikes): worldwide maps via a region-switching tile proxy

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.
This commit is contained in:
2026-05-22 16:26:22 +02:00
parent 5540d37c72
commit 2347a02fcb
19 changed files with 2993 additions and 53 deletions
+28 -15
View File
@@ -3,6 +3,7 @@
import type { HikeTrackPoint, ImagePoint, HikeStage } from '$types/hikes';
import { hover, setHover, clearHover } from './hoverStore.svelte';
import { stage } from './stageStore.svelte';
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
import { focused, setFocused, clearFocused } from './focusedImageStore.svelte';
import Map from '@lucide/svelte/icons/map';
import Satellite from '@lucide/svelte/icons/satellite';
@@ -39,6 +40,10 @@
* stageStore) the map highlights it, dims the rest, zooms to it, and
* scopes photo markers to that stage. */
stages?: HikeStage[] | null;
/** Whether the hike lies in a swisstopo-covered region (CH/LI). Drives
* the schematic max zoom (19 in-region vs 17 for OpenTopoMap abroad)
* and whether the CH/LI-only Dufour layer is offered. */
swissRegion?: boolean;
}
const {
@@ -49,7 +54,8 @@
initialZoom,
onReady,
trackColor,
stages = null
stages = null,
swissRegion = true
}: Props = $props();
// User-location toggle moved inside the map UI. localStorage-persisted so
@@ -101,19 +107,26 @@
enableUserLocation = true;
}
const SWISSTOPO_FARBE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg';
const SWISSTOPO_IMAGE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg';
const SWISSTOPO_DUFOUR = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hiks-dufour/default/current/3857/{z}/{x}/{y}.png';
const SWISSTOPO_ATTRIBUTION = '&copy; <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a>';
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = {
schematic: { label: 'Karte', icon: Map, maxZoom: 19 },
type LayerDef = { label: string; icon: typeof Map; maxZoom: number };
// Schematic max zoom is region-aware: swisstopo reaches z19 over CH/LI,
// but the global fallback (OpenTopoMap) only serves to z17.
const LAYER_DEFS: Record<BaseLayer, LayerDef> = $derived({
schematic: { label: 'Karte', icon: Map, maxZoom: swissRegion ? 19 : 17 },
aerial: { label: 'Luftbild', icon: Satellite, maxZoom: 19 },
// Dufour Map (18451864): swisstopo's historical layer, only goes up
// to roughly z16. We cap the map's maxZoom when this layer is active.
dufour: { label: 'Dufour (1864)', icon: Landmark, maxZoom: 16 }
};
});
// The Dufour historical layer exists only for CH/LI — hide it abroad.
const layerOptions = $derived(
Object.entries(LAYER_DEFS).filter(([key]) => swissRegion || key !== 'dufour') as [
BaseLayer,
LayerDef
][]
);
let showPhotos = $state(true);
let baseLayer = $state<BaseLayer>('schematic');
@@ -155,22 +168,22 @@
});
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
schematic: L.tileLayer(SWISSTOPO_FARBE, {
schematic: L.tileLayer(TILE_URL.karte, {
maxZoom: LAYER_DEFS.schematic.maxZoom,
minZoom: 7,
attribution: SWISSTOPO_ATTRIBUTION,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
}),
aerial: L.tileLayer(SWISSTOPO_IMAGE, {
aerial: L.tileLayer(TILE_URL.luftbild, {
maxZoom: LAYER_DEFS.aerial.maxZoom,
minZoom: 7,
attribution: SWISSTOPO_ATTRIBUTION,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
}),
dufour: L.tileLayer(SWISSTOPO_DUFOUR, {
dufour: L.tileLayer(TILE_URL.dufour, {
maxZoom: LAYER_DEFS.dufour.maxZoom,
minZoom: 7,
attribution: SWISSTOPO_ATTRIBUTION,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
})
};
@@ -675,7 +688,7 @@
</button>
{#if layerMenuOpen}
<div class="layer-popover" role="menu">
{#each Object.entries(LAYER_DEFS) as [key, def] (key)}
{#each layerOptions as [key, def] (key)}
{@const Icon = def.icon}
<button
type="button"
@@ -3,6 +3,8 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { sacTrailColor } from '$lib/data/sacColors';
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
import { isSwissRegion } from '$lib/hikes/hikeArea';
import type { HikeManifestEntry } from '$types/hikes';
import Map from '@lucide/svelte/icons/map';
import Satellite from '@lucide/svelte/icons/satellite';
@@ -28,18 +30,17 @@
const { hikes, initialCenter, initialZoom, onReady }: Props = $props();
const SWISSTOPO_FARBE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg';
const SWISSTOPO_IMAGE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg';
const SWISSTOPO_DUFOUR = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hiks-dufour/default/current/3857/{z}/{x}/{y}.png';
const SWISSTOPO_ATTRIBUTION =
'&copy; <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a>';
// When every displayed hike is in a swisstopo region (CH/LI), the schematic
// can use swisstopo's z19; with a hike abroad the global fallback is shallower
// so we cap a touch lower (still generous — the overview is a finder).
const allSwiss = $derived(hikes.every((h) => isSwissRegion(h.canton, h.country)));
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = {
schematic: { label: 'Karte', icon: Map, maxZoom: 19 },
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = $derived({
schematic: { label: 'Karte', icon: Map, maxZoom: allSwiss ? 19 : 18 },
aerial: { label: 'Luftbild', icon: Satellite, maxZoom: 19 },
dufour: { label: 'Dufour (1864)', icon: Landmark, maxZoom: 16 }
};
});
const GPS_STORAGE_KEY = 'hikes:gpsEnabled';
@@ -123,22 +124,22 @@
}
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
schematic: L.tileLayer(SWISSTOPO_FARBE, {
schematic: L.tileLayer(TILE_URL.karte, {
maxZoom: LAYER_DEFS.schematic.maxZoom,
minZoom: 7,
attribution: SWISSTOPO_ATTRIBUTION,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
}),
aerial: L.tileLayer(SWISSTOPO_IMAGE, {
aerial: L.tileLayer(TILE_URL.luftbild, {
maxZoom: LAYER_DEFS.aerial.maxZoom,
minZoom: 7,
attribution: SWISSTOPO_ATTRIBUTION,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
}),
dufour: L.tileLayer(SWISSTOPO_DUFOUR, {
dufour: L.tileLayer(TILE_URL.dufour, {
maxZoom: LAYER_DEFS.dufour.maxZoom,
minZoom: 7,
attribution: SWISSTOPO_ATTRIBUTION,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
})
};
@@ -381,7 +382,7 @@
<svelte:head>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="preconnect" href="https://wmts.geo.admin.ch" crossorigin="anonymous" />
<link rel="preconnect" href="https://maps.bocken.org" crossorigin="anonymous" />
</svelte:head>
<div class="map-wrap">
@@ -7,6 +7,10 @@
scheduleSave
} from './builderStore.svelte';
import { SAC_TRAIL_COLOR } from '$lib/data/sacColors';
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
import MapIcon from '@lucide/svelte/icons/map';
import Satellite from '@lucide/svelte/icons/satellite';
import Layers from '@lucide/svelte/icons/layers';
// Single-point Swisstopo elevation lookups are intentionally NOT used —
// they returned 0 against WGS-84 inputs in practice, and image waypoints
// don't need per-point altitudes anyway. Waypoint altitudes flow from
@@ -28,11 +32,30 @@
pendingPlacementId ? builder.waypoints.find((w) => w.id === pendingPlacementId) ?? null : null
);
// Schematic ↔ satellite base layer (satellite helps placing waypoints on
// trails/landmarks, esp. off the marked path). Same bottom-right layer
// popover as the detail / overview maps.
type BaseLayer = 'schematic' | 'aerial';
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof MapIcon }> = {
schematic: { label: 'Karte', icon: MapIcon },
aerial: { label: 'Luftbild', icon: Satellite }
};
let baseLayer = $state<BaseLayer>('schematic');
let layerMenuOpen = $state(false);
// Close the layer popover on outside click (the opening click stops
// propagation so this never sees it).
$effect(() => {
if (!layerMenuOpen) return;
function onAway(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target && !target.closest('.layer-menu')) layerMenuOpen = false;
}
window.addEventListener('click', onAway);
return () => window.removeEventListener('click', onAway);
});
const SWISSTOPO_FARBE =
'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg';
const SWISSTOPO_ATTRIBUTION =
'&copy; <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a>';
// Default view: Switzerland-wide.
const DEFAULT_CENTER: [number, number] = [46.8, 8.3];
const DEFAULT_ZOOM = 8;
@@ -101,12 +124,22 @@
preferCanvas: false
}).setView(DEFAULT_CENTER, DEFAULT_ZOOM);
L.tileLayer(SWISSTOPO_FARBE, {
maxZoom: 19,
minZoom: 7,
attribution: SWISSTOPO_ATTRIBUTION,
updateWhenZooming: false
}).addTo(map);
const tileLayers = {
schematic: L.tileLayer(TILE_URL.karte, {
maxZoom: 19,
minZoom: 7,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
}),
aerial: L.tileLayer(TILE_URL.luftbild, {
maxZoom: 19,
minZoom: 7,
attribution: TILE_ATTRIBUTION,
updateWhenZooming: false
})
};
tileLayers.schematic.addTo(map);
let currentBase: 'schematic' | 'aerial' = 'schematic';
const markerLayer = L.layerGroup().addTo(map);
const lineLayer = L.layerGroup().addTo(map);
@@ -252,6 +285,14 @@
// React to store changes.
const stopRoot = $effect.root(() => {
// Base-layer switch (schematic ↔ satellite).
$effect(() => {
if (baseLayer === currentBase) return;
tileLayers[currentBase].remove();
tileLayers[baseLayer].addTo(map);
currentBase = baseLayer;
});
$effect(() => {
// Touch each reactive field so we re-render on any mutation,
// including focus changes (so the active marker re-styles).
@@ -337,6 +378,45 @@
<div class="edit-map-wrap" class:placement-mode={!!pendingWaypoint}>
<div class="edit-map" {@attach editAttachment}></div>
<div class="map-controls">
<div class="layer-menu" class:open={layerMenuOpen}>
<button
type="button"
class="round-btn"
aria-label="Kartenebene wählen"
aria-haspopup="menu"
aria-expanded={layerMenuOpen}
onclick={(e) => {
e.stopPropagation();
layerMenuOpen = !layerMenuOpen;
}}
>
<Layers size={20} strokeWidth={2} aria-hidden="true" />
</button>
{#if layerMenuOpen}
<div class="layer-popover" role="menu">
{#each Object.entries(LAYER_DEFS) as [key, def] (key)}
{@const Icon = def.icon}
<button
type="button"
role="menuitemradio"
aria-checked={baseLayer === key}
class:active={baseLayer === key}
onclick={() => {
baseLayer = key as BaseLayer;
layerMenuOpen = false;
}}
>
<Icon size={14} strokeWidth={1.75} aria-hidden="true" />
{def.label}
</button>
{/each}
</div>
{/if}
</div>
</div>
{#if pendingWaypoint}
<div class="placement-banner" role="status">
<span>Klicke auf die Karte, um <strong>das Bild</strong> zu platzieren.</span>
@@ -387,6 +467,97 @@
cursor: crosshair;
}
/* Bottom-right round controls + layer popover — same language as the
* detail / overview maps. */
.map-controls {
position: absolute;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
z-index: 500;
}
.round-btn {
display: grid;
place-items: center;
width: 44px;
height: 44px;
background: var(--color-surface);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: 50%;
box-shadow: var(--shadow-md);
cursor: pointer;
transition:
color var(--transition-fast),
background var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.round-btn:hover {
color: var(--color-primary);
transform: scale(1.05);
box-shadow: var(--shadow-hover);
}
.layer-menu {
position: relative;
}
.layer-popover {
position: absolute;
right: calc(100% + 0.5rem);
bottom: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.3rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 9.5rem;
white-space: nowrap;
}
.layer-popover button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.7rem;
border: 0;
background: transparent;
color: var(--color-text-primary);
font: inherit;
font-size: 0.85rem;
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
transition: background var(--transition-fast), color var(--transition-fast);
}
.layer-popover button :global(svg) {
color: var(--color-text-tertiary);
flex: 0 0 auto;
}
.layer-popover button:hover {
background: var(--color-bg-elevated);
}
.layer-popover button.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.layer-popover button.active :global(svg) {
color: var(--color-text-on-primary);
}
.placement-banner {
position: absolute;
top: 0.75rem;
+30
View File
@@ -0,0 +1,30 @@
/**
* Map tile sources for the hikes maps.
*
* Tiles are served through the region-switching caching proxy (see
* `tile-proxy/`), which transparently picks swisstopo inside Switzerland and
* global providers (OpenTopoMap / Esri) elsewhere. The app just uses one
* canonical scheme and never talks to the providers directly.
*
* To point back at swisstopo directly (e.g. local dev without the proxy),
* change `TILE_BASE` here — it's the single switch.
*/
export const TILE_BASE = 'https://maps.bocken.org';
export const TILE_URL = {
/** Schematic / topographic ("Karte"). */
karte: `${TILE_BASE}/karte/{z}/{x}/{y}`,
/** Satellite ("Luftbild"). */
luftbild: `${TILE_BASE}/luftbild/{z}/{x}/{y}`,
/** Historical Dufour map — Switzerland only. */
dufour: `${TILE_BASE}/dufour/{z}/{x}/{y}`
} as const;
/** Combined attribution — the proxy may serve any provider depending on the
* region in view, so all three are credited. Shown in the page footer (the
* on-map control is disabled). */
export const TILE_ATTRIBUTION =
'&copy; <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a> · ' +
'&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>, ' +
'<a href="https://opentopomap.org/" target="_blank" rel="noopener">OpenTopoMap</a> · ' +
'&copy; <a href="https://www.esri.com/" target="_blank" rel="noopener">Esri</a>';
+12
View File
@@ -25,3 +25,15 @@ export function resolveHikeArea(
if (k) return { value: `country:${k.code}`, label: k.name, iconUrl: k.flagUrl, kind: 'country' };
return null;
}
/** Whether a hike sits in a swisstopo-covered region (Switzerland or
* Liechtenstein). Drives the schematic max-zoom (swisstopo reaches deeper than
* the global fallback) and whether the CH/LI-only Dufour layer is offered. */
export function isSwissRegion(
canton: string | null | undefined,
country: string | null | undefined
): boolean {
if (resolveCanton(canton)) return true;
const c = resolveCountry(country);
return c?.code === 'CH' || c?.code === 'LI';
}