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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.82.1",
"version": "1.83.0",
"private": true,
"type": "module",
"scripts": {
+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';
}
+6 -3
View File
@@ -253,9 +253,12 @@
credit to appear somewhere on the page. -->
<footer class="map-credit">
Kartendaten &copy;
<a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener noreferrer">
swisstopo
</a>
<a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener noreferrer">swisstopo</a>
·
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a>,
<a href="https://opentopomap.org/" target="_blank" rel="noopener noreferrer">OpenTopoMap</a>
·
<a href="https://www.esri.com/" target="_blank" rel="noopener noreferrer">Esri</a>
</footer>
</div>
</section>
+11 -5
View File
@@ -4,6 +4,7 @@
import HikeStageNav from '$lib/components/hikes/HikeStageNav.svelte';
import ElevationProfile from '$lib/components/hikes/ElevationProfile.svelte';
import { stage, clearActiveStage } from '$lib/components/hikes/stageStore.svelte';
import { isSwissRegion } from '$lib/hikes/hikeArea';
import Seo from '$lib/components/Seo.svelte';
import { setHikeContext } from '$lib/components/hikes/hikeContext.svelte';
import { listScrollAnchors } from '$lib/components/hikes/scrollAnchors';
@@ -55,6 +56,9 @@
const canton = $derived(resolveCanton(hike.canton));
const trackColor = $derived(sacTrailColor(hike.difficulty));
// swisstopo covers CH + LI; abroad the schematic caps lower (OpenTopoMap z17)
// and the Dufour layer is unavailable.
const inSwissRegion = $derived(isSwissRegion(hike.canton, hike.country));
// Publish date formatted in long German for the meta footer
// (matches the hike's `date: YYYY-MM-DD` frontmatter format).
@@ -287,7 +291,7 @@
type="application/json"
crossorigin="anonymous"
/>
<link rel="preconnect" href="https://wmts.geo.admin.ch" crossorigin="anonymous" />
<link rel="preconnect" href="https://maps.bocken.org" crossorigin="anonymous" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</svelte:head>
@@ -357,6 +361,7 @@
showPrivate
{trackColor}
{stages}
swissRegion={inSwissRegion}
initialCenter={heroPose?.center}
initialZoom={heroPose?.zoom}
onReady={() => (heroMapReady = true)}
@@ -476,7 +481,7 @@
<section class="scroll-area">
<aside class="trail-col">
{#if track && track.length > 0}
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate {trackColor} {stages} />
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate {trackColor} {stages} swissRegion={inSwissRegion} />
<ElevationProfile {track} viewRange={stageViewRange} />
{/if}
</aside>
@@ -510,9 +515,10 @@
<span class="meta-dot" aria-hidden="true">·</span>
<span>
Kartendaten &copy;
<a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener noreferrer">
swisstopo
</a>
<a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener noreferrer">swisstopo</a>,
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a>,
<a href="https://opentopomap.org/" target="_blank" rel="noopener noreferrer">OpenTopoMap</a>,
<a href="https://www.esri.com/" target="_blank" rel="noopener noreferrer">Esri</a>
</span>
</footer>
</article>
+7 -4
View File
@@ -335,7 +335,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>
<section class="builder">
@@ -495,9 +495,12 @@
their tile licence still requires the credit to appear on the page. -->
<footer class="map-credit">
Kartendaten &copy;
<a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener noreferrer">
swisstopo
</a>
<a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener noreferrer">swisstopo</a>
·
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a>,
<a href="https://opentopomap.org/" target="_blank" rel="noopener noreferrer">OpenTopoMap</a>
·
<a href="https://www.esri.com/" target="_blank" rel="noopener noreferrer">Esri</a>
</footer>
</section>
+2
View File
@@ -0,0 +1,2 @@
/target
/tile-proxy
+1460
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "tile-proxy"
version = "0.1.0"
edition = "2021"
description = "Region-switching map tile proxy: swisstopo inside Switzerland, global providers elsewhere."
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
[profile.release]
opt-level = 3
lto = true
strip = true
+20
View File
@@ -0,0 +1,20 @@
BIN := tile-proxy
.PHONY: build run regions clean
# Build release binary and drop it in ./ (next to this Makefile).
build:
cargo build --release
cp target/release/$(BIN) ./$(BIN)
# Build, then run (override the port with TILE_PROXY_ADDR=...).
run: build
./$(BIN)
# Regenerate the swisstopo-region polygons CH + LI (src/regions.in).
regions:
node scripts/gen-regions.mjs
clean:
cargo clean
rm -f ./$(BIN)
+96
View File
@@ -0,0 +1,96 @@
# tile-proxy
A tiny region-switching map-tile service for the hikes maps. It exposes one
canonical XYZ scheme and, **per tile**, serves **swisstopo inside Switzerland
& Liechtenstein** and a **global provider elsewhere** — so a hike anywhere in
the world gets a good schematic + satellite basemap, while CH/LI hikes keep
swisstopo quality.
```
browser / build script
│ https://maps.bocken.org/{layer}/{z}/{x}/{y}
nginx ── TLS termination + on-disk tile cache (proxy_cache)
▼ http://$TILE_PROXY_ADDR (default 127.0.0.1:8765)
tile-proxy (this crate) ── z/x/y → point-in-polygon → pick + fetch upstream
swisstopo │ OpenTopoMap │ Esri World Imagery
```
The listen address is set entirely by the **`TILE_PROXY_ADDR`** env var
(`host:port`); pick whatever port you like. nginx, the systemd unit and this
binary all reference that one value.
Caching/TLS/rate-limiting live in **nginx** (Arch's stock nginx has no njs, and
we want a real geometry test anyway). This binary only does routing + fetch and
is stateless.
## Layers & providers
| `{layer}` | swisstopo region (CH + LI) | elsewhere |
|------------|----------------------------------|-----------------------|
| `karte` | `ch.swisstopo.pixelkarte-farbe` | OpenTopoMap |
| `luftbild` | `ch.swisstopo.swissimage` | Esri World Imagery |
| `dufour` | `ch.swisstopo.hiks-dufour` | — (CH/LI-only) |
The swisstopo region is **Switzerland + Liechtenstein** (swisstopo has
high-quality data for both). A tile is served by swisstopo when it **overlaps**
that region at all — the tile's lat/lng rectangle is intersected against the
polygons (`src/regions.in`, simplified + a **2 km outward buffer**), so *any*
tile touching CH/LI gets swisstopo, at every zoom. (A swisstopo tile partly
outside its data just renders white at the edges, which beats a foreign
provider drawing over covered ground.) For `karte`/`luftbild`, a swisstopo tile
that 404s near the edge falls back to the global provider automatically.
## Build & run
```sh
cargo build --release
TILE_PROXY_ADDR=127.0.0.1:8765 ./target/release/tile-proxy
# smoke test
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
http://127.0.0.1:8765/karte/9/266/180 # Bern → swisstopo (jpeg)
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
http://127.0.0.1:8765/karte/9/255/171 # London → OpenTopoMap (png)
```
`TILE_PROXY_ADDR` defaults to `127.0.0.1:8765` but should be set explicitly.
`GET /healthz` returns `ok`.
Run it as a service with [`deploy/tile-proxy.service`](deploy/tile-proxy.service)
and put nginx in front with [`deploy/nginx.conf.example`](deploy/nginx.conf.example).
## Regenerating the region polygons
`src/regions.in` is generated — don't hand-edit it. To refresh (or change the
buffer / simplification, or add a region):
```sh
make regions # CH + LI, default 2 km buffer
BUFFER_KM=2 SIMPLIFY_DEG=0.004 node scripts/gen-regions.mjs
node scripts/gen-regions.mjs ./ch.geojson ./li.geojson # local files
```
It takes each source's largest exterior ring, DouglasPeuckersimplifies it,
then pushes every vertex outward by `BUFFER_KM`. Rebuild the binary afterwards
(the ring is baked in via `include!`). If you move the boundary, purge the nginx
cache so old tiles aren't served by the previous provider.
## Attribution (required)
All three providers require credit — keep these on the page (the hikes pages
show them in the footer):
- **© swisstopo** — Swiss tiles.
- **© OpenStreetMap contributors, SRTM | © OpenTopoMap (CC-BY-SA)** — world schematic.
- **© Esri, Maxar, Earthstar Geographics** — world satellite.
## Notes
- **Max zoom** differs by provider — clamp the client: OpenTopoMap 17,
Esri imagery ~19, swisstopo 19/20.
- **OpenTopoMap fair-use:** it's a small volunteer project; the long nginx
cache + a descriptive `User-Agent` is the mitigation. If traffic grows,
self-host a renderer.
- **Dufour** has no world equivalent — the client hides that layer when the
view leaves Switzerland.
+72
View File
@@ -0,0 +1,72 @@
# Caching reverse proxy for the tile-proxy service.
#
# Install as a site:
# cp nginx.conf.example /etc/nginx/sites-available/maps
# ln -s ../sites-available/maps /etc/nginx/sites-enabled/maps
# nginx -t && systemctl reload nginx
#
# The `proxy_cache_path`, `limit_req_zone` and `upstream` directives are
# http-context. This works because Arch/Debian's `include sites-enabled/*`
# runs inside `http {}`. If your include lives elsewhere, move those three
# blocks into your main `http {}` instead.
#
# The upstream port (127.0.0.1:8765) must match TILE_PROXY_ADDR in
# tile-proxy.service. To drive it from the env instead, render with envsubst:
# export TILE_PROXY_ADDR=127.0.0.1:8765
# envsubst '$TILE_PROXY_ADDR' < nginx.conf.example > /etc/nginx/sites-available/maps
# (then change `server 127.0.0.1:8765;` below to `server ${TILE_PROXY_ADDR};`)
proxy_cache_path /var/cache/nginx/tiles
levels=1:2 keys_zone=tiles:50m max_size=20g inactive=180d
use_temp_path=off;
limit_req_zone $binary_remote_addr zone=tiles_rl:10m rate=50r/s;
upstream tile_proxy {
server 127.0.0.1:8765; # == TILE_PROXY_ADDR
keepalive 16;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on; # modern form (NOT `listen ... http2`, deprecated)
server_name maps.bocken.org;
ssl_certificate /etc/letsencrypt/live/maps.bocken.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/maps.bocken.org/privkey.pem;
# Canonical tile scheme: /{karte|luftbild|dufour}/{z}/{x}/{y}
location ~ ^/(?:karte|luftbild|dufour)/\d+/\d+/\d+$ {
limit_req zone=tiles_rl burst=200 nodelay;
proxy_cache tiles;
proxy_cache_key $uri; # layer/z/x/y — provider-agnostic
proxy_cache_valid 200 180d;
proxy_cache_valid 404 502 1h;
proxy_cache_use_stale error timeout updating;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_ignore_headers Set-Cookie Vary; # keep upstream Cache-Control
add_header X-Cache-Status $upstream_cache_status always;
add_header Access-Control-Allow-Origin "*" always; # app + build fetch tiles
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_pass http://tile_proxy;
}
location = /healthz {
proxy_pass http://tile_proxy;
access_log off;
}
}
# http → https
server {
listen 80;
listen [::]:80;
server_name maps.bocken.org;
location / { return 301 https://$host$request_uri; }
}
+29
View File
@@ -0,0 +1,29 @@
# systemd unit for the tile proxy.
# install: cp deploy/tile-proxy.service /etc/systemd/system/
# (build first: cargo build --release; adjust paths/user below)
# enable: systemctl daemon-reload && systemctl enable --now tile-proxy
[Unit]
Description=Region-switching map tile proxy (swisstopo / world)
After=network-online.target
Wants=network-online.target
[Service]
# The one place the port is configured — nginx must point at the same value.
Environment=TILE_PROXY_ADDR=127.0.0.1:8765
ExecStart=/opt/tile-proxy/tile-proxy
Restart=on-failure
RestartSec=2
# Hardening — the service only needs outbound network.
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_INET AF_INET6
MemoryMax=128M
[Install]
WantedBy=multi-user.target
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Generate `src/regions.in` — the polygons the tile proxy treats as
* "swisstopo-covered": Switzerland and Liechtenstein (swisstopo has
* high-quality data for both). A tile overlapping any of them is served by
* swisstopo; everything else by the global providers.
*
* Pipeline per source: largest exterior ring → DouglasPeucker simplify →
* push every vertex ~BUFFER_KM outward (so border tiles still prefer
* swisstopo, which covers a margin past the border). Output is a Rust array of
* rings `&[ &[[lng,lat],…], … ]` consumed via `include!`.
*
* node scripts/gen-regions.mjs [geojson-url-or-path …]
* BUFFER_KM=2 SIMPLIFY_DEG=0.004 node scripts/gen-regions.mjs
*/
import { writeFileSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const SOURCES =
process.argv.slice(2).length > 0
? process.argv.slice(2)
: [
'https://raw.githubusercontent.com/georgique/world-geojson/develop/countries/switzerland.json',
'https://raw.githubusercontent.com/georgique/world-geojson/develop/countries/liechtenstein.json'
];
const BUFFER_KM = Number(process.env.BUFFER_KM ?? 2);
const SIMPLIFY_DEG = Number(process.env.SIMPLIFY_DEG ?? 0.004); // ≈ 440 m
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'regions.in');
async function load(src) {
if (/^https?:/.test(src)) {
const r = await fetch(src);
if (!r.ok) throw new Error(`fetch ${src}: ${r.status}`);
return r.json();
}
return JSON.parse(readFileSync(src, 'utf8'));
}
function exteriorRings(geojson) {
const rings = [];
const visit = (g) => {
if (!g) return;
if (g.type === 'FeatureCollection') g.features.forEach((f) => visit(f.geometry));
else if (g.type === 'Feature') visit(g.geometry);
else if (g.type === 'Polygon') rings.push(g.coordinates[0]);
else if (g.type === 'MultiPolygon') g.coordinates.forEach((p) => rings.push(p[0]));
};
visit(geojson);
return rings;
}
function signedArea(ring) {
let a = 0;
for (let i = 0, n = ring.length, j = n - 1; i < n; j = i++) {
a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
}
return a / 2;
}
function perpDist(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const L = dx * dx + dy * dy;
if (L === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / L;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
function simplify(points, eps) {
if (points.length < 3) return points;
const keep = new Array(points.length).fill(false);
keep[0] = keep[points.length - 1] = true;
const stack = [[0, points.length - 1]];
while (stack.length) {
const [s, e] = stack.pop();
let dmax = 0;
let idx = -1;
for (let i = s + 1; i < e; i++) {
const d = perpDist(points[i], points[s], points[e]);
if (d > dmax) {
dmax = d;
idx = i;
}
}
if (dmax > eps && idx > 0) {
keep[idx] = true;
stack.push([s, idx], [idx, e]);
}
}
return points.filter((_, i) => keep[i]);
}
function bufferOutward(ring, km, lat0) {
const k = Math.cos((lat0 * Math.PI) / 180);
const d = km / 111.32;
const n = ring.length;
const edgeNormal = (a, b) => {
const dx = (b[0] - a[0]) * k;
const dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
return [dy / len, -dx / len];
};
const out = [];
for (let i = 0; i < n; i++) {
const prev = ring[(i - 1 + n) % n];
const cur = ring[i];
const next = ring[(i + 1) % n];
const [ax, ay] = edgeNormal(prev, cur);
const [bx, by] = edgeNormal(cur, next);
let nx = ax + bx;
let ny = ay + by;
const len = Math.hypot(nx, ny) || 1;
nx /= len;
ny /= len;
out.push([cur[0] + (nx * d) / k, cur[1] + ny * d]);
}
return out;
}
async function buildRing(src) {
const gj = await load(src);
let ring = exteriorRings(gj).sort((a, b) => Math.abs(signedArea(b)) - Math.abs(signedArea(a)))[0];
if (!ring) throw new Error(`no polygon rings in ${src}`);
if (ring.length > 1 && ring[0][0] === ring.at(-1)[0] && ring[0][1] === ring.at(-1)[1]) {
ring = ring.slice(0, -1);
}
if (signedArea(ring) < 0) ring = ring.slice().reverse();
ring = simplify(ring, SIMPLIFY_DEG);
const lat0 = ring.reduce((s, p) => s + p[1], 0) / ring.length;
return bufferOutward(ring, BUFFER_KM, lat0);
}
const rings = [];
for (const src of SOURCES) rings.push(await buildRing(src));
const ringLit = (ring) =>
'\t&[\n' + ring.map((p) => `\t\t[${p[0].toFixed(5)}, ${p[1].toFixed(5)}]`).join(',\n') + '\n\t]';
const total = rings.reduce((s, r) => s + r.length, 0);
const all = rings.flat();
const lngs = all.map((p) => p[0]);
const lats = all.map((p) => p[1]);
writeFileSync(
OUT,
`// Auto-generated by scripts/gen-regions.mjs — do not edit by hand.\n` +
`// swisstopo-covered regions (CH + LI), simplified (${SIMPLIFY_DEG}°) + ${BUFFER_KM} km buffer.\n` +
`// ${rings.length} rings, ${total} points · lng [${Math.min(...lngs).toFixed(2)}, ${Math.max(...lngs).toFixed(2)}] · lat [${Math.min(...lats).toFixed(2)}, ${Math.max(...lats).toFixed(2)}]\n` +
`&[\n${rings.map(ringLit).join(',\n')}\n]\n`
);
console.log(
`wrote ${OUT}: ${rings.length} rings, ${total} points (${rings.map((r) => r.length).join(' + ')})`
);
+207
View File
@@ -0,0 +1,207 @@
//! Region-switching map tile proxy.
//!
//! Exposes one canonical XYZ scheme — `/{layer}/{z}/{x}/{y}` for
//! `layer ∈ {karte, luftbild, dufour}` — and, per tile, serves swisstopo when
//! the tile overlaps a swisstopo-covered region (Switzerland or Liechtenstein,
//! each buffered ~2 km) and a global provider otherwise:
//!
//! | layer | swisstopo region (CH + LI) | elsewhere |
//! |----------|------------------------------|----------------------------|
//! | karte | ch.swisstopo.pixelkarte-farbe| OpenTopoMap |
//! | luftbild | ch.swisstopo.swissimage | Esri World Imagery |
//! | dufour | ch.swisstopo.hiks-dufour | — (CH/LI-only historical) |
//!
//! Caching, TLS and rate-limiting live in nginx in front of this service
//! (see README.md) — this binary only does the routing + upstream fetch.
use std::net::SocketAddr;
use std::sync::OnceLock;
use std::time::Duration;
use axum::{
extract::Path,
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
/// Polygons swisstopo covers — Switzerland and Liechtenstein — each a ring
/// `[[lng, lat], …]` (simplified + ~2 km outward buffer). Regenerate with
/// `node scripts/gen-regions.mjs`.
static REGIONS: &[&[[f64; 2]]] = include!("regions.in");
/// Generous bounding box (west, south, east, north) over all regions, for a
/// cheap reject before the full polygon intersection test.
const REGION_BBOX: (f64, f64, f64, f64) = (5.8, 45.7, 10.7, 48.0);
fn client() -> &'static reqwest::Client {
static C: OnceLock<reqwest::Client> = OnceLock::new();
C.get_or_init(|| {
reqwest::Client::builder()
.user_agent("bocken.org hiking tile cache (+https://bocken.org)")
.timeout(Duration::from_secs(20))
.build()
.expect("build reqwest client")
})
}
/// Ray-casting point-in-polygon for a single ring.
fn point_in_ring(ring: &[[f64; 2]], lng: f64, lat: f64) -> bool {
let n = ring.len();
let mut inside = false;
let mut j = n - 1;
for i in 0..n {
let (xi, yi) = (ring[i][0], ring[i][1]);
let (xj, yj) = (ring[j][0], ring[j][1]);
if (yi > lat) != (yj > lat) && lng < (xj - xi) * (lat - yi) / (yj - yi) + xi {
inside = !inside;
}
j = i;
}
inside
}
/// Web-mercator tile bounds → (west, south, east, north) in degrees.
fn tile_bounds(z: u32, x: u32, y: u32) -> (f64, f64, f64, f64) {
let n = 2f64.powi(z as i32);
let lat = |yy: f64| {
(std::f64::consts::PI * (1.0 - 2.0 * yy / n))
.sinh()
.atan()
.to_degrees()
};
let west = x as f64 / n * 360.0 - 180.0;
let east = (x as f64 + 1.0) / n * 360.0 - 180.0;
(west, lat(y as f64 + 1.0), east, lat(y as f64)) // y increases southward
}
/// Do segments p1p2 and p3p4 strictly cross?
fn segments_cross(p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], p4: [f64; 2]) -> bool {
let o = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
};
(o(p3, p4, p1) > 0.0) != (o(p3, p4, p2) > 0.0)
&& (o(p1, p2, p3) > 0.0) != (o(p1, p2, p4) > 0.0)
}
/// True if the tile's lat/lng rectangle intersects any swisstopo region
/// (CH or LI) — i.e. any part of the tile lies within the covered area
/// (corner inside, region vertex inside the tile, or edges crossing).
fn tile_intersects_regions(z: u32, x: u32, y: u32) -> bool {
let (w, s, e, n) = tile_bounds(z, x, y);
let (bw, bs, be, bn) = REGION_BBOX;
if e < bw || w > be || n < bs || s > bn {
return false; // tile bbox doesn't even touch the regions' bbox
}
let corners = [[w, s], [e, s], [e, n], [w, n]];
for ring in REGIONS {
if corners.iter().any(|c| point_in_ring(ring, c[0], c[1])) {
return true; // a tile corner is inside this region
}
if ring
.iter()
.any(|p| p[0] >= w && p[0] <= e && p[1] >= s && p[1] <= n)
{
return true; // a region vertex is inside the tile
}
let len = ring.len();
for i in 0..len {
let a = ring[i];
let b = ring[(i + 1) % len];
for k in 0..4 {
if segments_cross(a, b, corners[k], corners[(k + 1) % 4]) {
return true; // region border crosses a tile edge
}
}
}
}
false
}
fn swisstopo(bod: &str, ext: &str, z: u32, x: u32, y: u32) -> String {
format!("https://wmts.geo.admin.ch/1.0.0/{bod}/default/current/3857/{z}/{x}/{y}.{ext}")
}
/// `(primary, fallback)` upstream URLs for a layer + tile. `fallback` is tried
/// when the primary has no tile (e.g. a swisstopo border tile just outside its
/// coverage) — it's the global provider, or `None` when there's no alternative.
fn upstreams(layer: &str, z: u32, x: u32, y: u32) -> Option<(String, Option<String>)> {
// Favour the swisstopo regions (CH + LI) at every zoom: if the tile touches
// either at all, use swisstopo (a swisstopo tile partly outside its data
// just renders white at the edges, preferable to a foreign provider over
// covered ground).
let swiss = tile_intersects_regions(z, x, y);
let opentopo = || format!("https://a.tile.opentopomap.org/{z}/{x}/{y}.png");
// NB: Esri uses {z}/{y}/{x} (row before column).
let esri = || {
format!("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}")
};
match layer {
"karte" if swiss => Some((swisstopo("ch.swisstopo.pixelkarte-farbe", "jpeg", z, x, y), Some(opentopo()))),
"karte" => Some((opentopo(), None)),
"luftbild" if swiss => Some((swisstopo("ch.swisstopo.swissimage", "jpeg", z, x, y), Some(esri()))),
"luftbild" => Some((esri(), None)),
// Historical Dufour map only exists for Switzerland.
"dufour" => Some((swisstopo("ch.swisstopo.hiks-dufour", "png", z, x, y), None)),
_ => None,
}
}
async fn fetch(url: &str) -> Option<Response> {
let r = client().get(url).send().await.ok()?;
if !r.status().is_success() {
return None;
}
let content_type = r.headers().get(header::CONTENT_TYPE).cloned();
let body = r.bytes().await.ok()?;
let mut headers = HeaderMap::new();
if let Some(ct) = content_type {
headers.insert(header::CONTENT_TYPE, ct);
}
// Tiles are effectively immutable — let the nginx cache hold them long.
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=15552000"),
);
Some((StatusCode::OK, headers, body.to_vec()).into_response())
}
async fn tile(Path((layer, z, x, y)): Path<(String, u32, u32, u32)>) -> Response {
let Some((primary, fallback)) = upstreams(&layer, z, x, y) else {
return StatusCode::NOT_FOUND.into_response();
};
if let Some(resp) = fetch(&primary).await {
return resp;
}
if let Some(fb) = fallback {
if let Some(resp) = fetch(&fb).await {
return resp;
}
}
StatusCode::BAD_GATEWAY.into_response()
}
#[tokio::main]
async fn main() {
// Bind address is fully env-driven; the default is just a convenience for
// `cargo run` and is deliberately off the common 8080/8088 range.
let addr: SocketAddr = std::env::var("TILE_PROXY_ADDR")
.unwrap_or_else(|_| "127.0.0.1:8765".to_string())
.parse()
.expect("TILE_PROXY_ADDR must be host:port, e.g. 127.0.0.1:8765");
let app = Router::new()
.route("/:layer/:z/:x/:y", get(tile))
.route("/healthz", get(|| async { "ok" }));
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("bind TILE_PROXY_ADDR");
println!(
"tile-proxy on http://{addr} ({} regions, {} border points)",
REGIONS.len(),
REGIONS.iter().map(|r| r.len()).sum::<usize>()
);
axum::serve(listener, app).await.expect("serve");
}
+646
View File
@@ -0,0 +1,646 @@
// Auto-generated by scripts/gen-regions.mjs — do not edit by hand.
// swisstopo-covered regions (CH + LI), simplified (0.004°) + 2 km buffer.
// 2 rings, 637 points · lng [5.94, 10.52] · lat [45.81, 47.82]
&[
&[
[8.54181, 47.81430],
[8.55541, 47.79081],
[8.54472, 47.79989],
[8.45461, 47.77946],
[8.43231, 47.73836],
[8.38021, 47.70409],
[8.39355, 47.68377],
[8.38011, 47.66681],
[8.43865, 47.63607],
[8.45126, 47.63805],
[8.47610, 47.62440],
[8.48744, 47.63351],
[8.49156, 47.62593],
[8.54677, 47.63335],
[8.54055, 47.64566],
[8.55038, 47.64037],
[8.56129, 47.65385],
[8.56070, 47.65255],
[8.58175, 47.64558],
[8.60197, 47.65323],
[8.60581, 47.65009],
[8.60780, 47.65254],
[8.62734, 47.64709],
[8.63882, 47.64667],
[8.60758, 47.67040],
[8.57179, 47.64631],
[8.58062, 47.61923],
[8.57327, 47.61305],
[8.58701, 47.60602],
[8.59854, 47.61881],
[8.51039, 47.64993],
[8.49273, 47.63201],
[8.46135, 47.62648],
[8.43392, 47.60372],
[8.45341, 47.56877],
[8.47845, 47.57146],
[8.46907, 47.58806],
[8.43474, 47.58615],
[8.38560, 47.59267],
[8.37553, 47.58190],
[8.33567, 47.58759],
[8.31941, 47.59732],
[8.31996, 47.61674],
[8.27848, 47.63061],
[8.27245, 47.62644],
[8.26556, 47.63200],
[8.23635, 47.62166],
[8.22744, 47.63705],
[8.18946, 47.63516],
[8.15300, 47.61067],
[8.13439, 47.61032],
[8.12509, 47.59941],
[8.09070, 47.59696],
[8.07810, 47.57349],
[8.08570, 47.57549],
[8.06800, 47.58243],
[8.01373, 47.56890],
[7.94918, 47.57334],
[7.93781, 47.56071],
[7.93096, 47.56225],
[7.93156, 47.56024],
[7.93971, 47.57114],
[7.89643, 47.60621],
[7.84954, 47.60457],
[7.84326, 47.59832],
[7.80484, 47.60432],
[7.78864, 47.57918],
[7.77951, 47.57245],
[7.69024, 47.54997],
[7.67911, 47.55165],
[7.65781, 47.55468],
[7.69332, 47.54987],
[7.68108, 47.55255],
[7.68452, 47.54744],
[7.71487, 47.57551],
[7.69795, 47.58490],
[7.71480, 47.61194],
[7.66997, 47.60937],
[7.63835, 47.61415],
[7.61025, 47.59395],
[7.62253, 47.59142],
[7.62270, 47.59774],
[7.57654, 47.60557],
[7.57007, 47.59025],
[7.56144, 47.59516],
[7.53224, 47.57925],
[7.53385, 47.57281],
[7.47161, 47.54236],
[7.49326, 47.51203],
[7.51475, 47.51672],
[7.50523, 47.52309],
[7.50969, 47.53014],
[7.51679, 47.53126],
[7.48577, 47.53719],
[7.48515, 47.50166],
[7.47550, 47.49835],
[7.47742, 47.49822],
[7.42772, 47.51527],
[7.39627, 47.47479],
[7.43164, 47.46876],
[7.39289, 47.45258],
[7.30106, 47.45631],
[7.25554, 47.43660],
[7.24761, 47.45360],
[7.20080, 47.45333],
[7.19410, 47.45090],
[7.20864, 47.47541],
[7.22688, 47.48328],
[7.21226, 47.51012],
[7.17136, 47.50716],
[7.13071, 47.52122],
[7.07793, 47.50638],
[7.02371, 47.52293],
[6.96252, 47.50308],
[6.96409, 47.46522],
[6.97697, 47.45626],
[6.97989, 47.46423],
[6.95510, 47.46122],
[6.94778, 47.44909],
[6.92240, 47.44659],
[6.92026, 47.41912],
[6.89551, 47.41769],
[6.89060, 47.39831],
[6.86439, 47.38584],
[6.85972, 47.34331],
[6.98539, 47.34338],
[7.01319, 47.35435],
[7.02846, 47.35093],
[7.02726, 47.34094],
[7.03670, 47.33874],
[7.03411, 47.34387],
[7.03367, 47.34282],
[6.98297, 47.32983],
[6.98952, 47.31292],
[6.98737, 47.31276],
[6.98724, 47.31251],
[6.95645, 47.31007],
[6.95355, 47.30322],
[6.91938, 47.29672],
[6.92590, 47.26597],
[6.92405, 47.24477],
[6.92996, 47.24580],
[6.85786, 47.21024],
[6.85267, 47.19689],
[6.81560, 47.16958],
[6.83434, 47.16233],
[6.80028, 47.15152],
[6.71554, 47.11426],
[6.72070, 47.10023],
[6.72935, 47.10794],
[6.71770, 47.10889],
[6.67815, 47.08533],
[6.68133, 47.08028],
[6.66583, 47.06867],
[6.69192, 47.05144],
[6.68731, 47.05366],
[6.67776, 47.05543],
[6.63347, 47.03567],
[6.62398, 47.01529],
[6.60666, 47.00959],
[6.59419, 47.01068],
[6.51303, 46.98368],
[6.50005, 46.99342],
[6.40643, 46.93069],
[6.43665, 46.88990],
[6.43568, 46.85629],
[6.41649, 46.83711],
[6.41761, 46.82450],
[6.40639, 46.81731],
[6.41451, 46.78923],
[6.43427, 46.78288],
[6.42971, 46.78414],
[6.42045, 46.77659],
[6.37686, 46.76048],
[6.37164, 46.74979],
[6.34512, 46.73563],
[6.34785, 46.73136],
[6.26458, 46.70378],
[6.24676, 46.68732],
[6.08356, 46.57874],
[6.13063, 46.54254],
[6.13658, 46.54522],
[6.13305, 46.54841],
[6.09132, 46.52007],
[6.07562, 46.49232],
[6.04710, 46.46991],
[6.05922, 46.44438],
[6.04085, 46.41026],
[6.08188, 46.39614],
[6.08513, 46.38857],
[6.14540, 46.35956],
[6.14965, 46.36242],
[6.14363, 46.36708],
[6.09941, 46.32333],
[6.09504, 46.30343],
[6.07689, 46.28870],
[6.09873, 46.25587],
[6.10117, 46.25647],
[6.09558, 46.26367],
[6.05226, 46.26071],
[6.04341, 46.25011],
[6.03181, 46.25599],
[5.94537, 46.21444],
[5.94658, 46.20750],
[5.93779, 46.19521],
[5.96874, 46.17805],
[5.93987, 46.11553],
[5.99693, 46.12654],
[6.04355, 46.11760],
[6.06393, 46.13539],
[6.08015, 46.13500],
[6.08650, 46.12845],
[6.13942, 46.12310],
[6.21158, 46.15834],
[6.21115, 46.17233],
[6.23496, 46.18417],
[6.31200, 46.21181],
[6.33491, 46.23954],
[6.32928, 46.26868],
[6.31435, 46.26952],
[6.30588, 46.28079],
[6.26724, 46.26596],
[6.27994, 46.26410],
[6.26508, 46.27634],
[6.27776, 46.28725],
[6.26799, 46.31352],
[6.24478, 46.31791],
[6.27303, 46.34918],
[6.34580, 46.38693],
[6.43490, 46.39913],
[6.52735, 46.43851],
[6.67873, 46.43663],
[6.79867, 46.41949],
[6.77575, 46.38597],
[6.78137, 46.38175],
[6.77908, 46.38282],
[6.75542, 46.37778],
[6.74494, 46.35399],
[6.77754, 46.31419],
[6.78523, 46.31693],
[6.78796, 46.30547],
[6.80107, 46.30482],
[6.81146, 46.28929],
[6.84587, 46.27221],
[6.84882, 46.27514],
[6.84044, 46.27828],
[6.83295, 46.26411],
[6.79602, 46.23768],
[6.79598, 46.22626],
[6.77749, 46.20732],
[6.78646, 46.18419],
[6.76607, 46.16583],
[6.77735, 46.12543],
[6.81120, 46.11218],
[6.83900, 46.11431],
[6.87652, 46.11541],
[6.85744, 46.09611],
[6.86518, 46.08128],
[6.85534, 46.07647],
[6.85037, 46.04686],
[6.88910, 46.02382],
[6.93533, 46.04820],
[6.91834, 46.05173],
[6.91511, 46.04520],
[6.93278, 46.03838],
[6.96754, 45.99417],
[6.98715, 45.98722],
[6.99581, 45.98145],
[6.98545, 45.97456],
[7.00217, 45.94722],
[7.01499, 45.94534],
[7.01051, 45.93527],
[7.02071, 45.91454],
[7.09353, 45.84483],
[7.13507, 45.84447],
[7.15659, 45.86058],
[7.19258, 45.84064],
[7.23127, 45.87383],
[7.24354, 45.87557],
[7.25999, 45.86918],
[7.29979, 45.90430],
[7.30235, 45.90374],
[7.31354, 45.89114],
[7.34357, 45.89739],
[7.38520, 45.87922],
[7.45620, 45.91402],
[7.49755, 45.92566],
[7.49512, 45.94303],
[7.50382, 45.94353],
[7.56015, 45.94431],
[7.56547, 45.97204],
[7.56182, 45.97032],
[7.56454, 45.97189],
[7.57105, 45.95680],
[7.65368, 45.95332],
[7.65355, 45.95800],
[7.66658, 45.93979],
[7.68738, 45.94199],
[7.68470, 45.92107],
[7.72588, 45.90597],
[7.75843, 45.91556],
[7.75888, 45.92322],
[7.76313, 45.91773],
[7.80117, 45.89885],
[7.82852, 45.90867],
[7.87985, 45.90241],
[7.90442, 45.92424],
[7.89431, 45.94054],
[7.89748, 45.93959],
[7.89888, 45.96215],
[7.91104, 45.96221],
[7.92439, 45.98205],
[7.99421, 45.98014],
[8.03708, 46.00678],
[8.03733, 46.02291],
[8.06048, 46.04004],
[8.04967, 46.07300],
[8.05243, 46.08921],
[8.12847, 46.09955],
[8.13416, 46.11872],
[8.16755, 46.12431],
[8.17550, 46.15780],
[8.19035, 46.17739],
[8.15555, 46.23940],
[8.14131, 46.24399],
[8.12777, 46.26147],
[8.10566, 46.26243],
[8.14459, 46.28473],
[8.15993, 46.27778],
[8.22669, 46.29251],
[8.24347, 46.32023],
[8.28802, 46.33814],
[8.28803, 46.35384],
[8.27955, 46.34964],
[8.29257, 46.34632],
[8.33911, 46.37373],
[8.32989, 46.41443],
[8.31332, 46.41269],
[8.31825, 46.40907],
[8.33178, 46.40724],
[8.37265, 46.43573],
[8.38573, 46.43223],
[8.43301, 46.44695],
[8.43949, 46.44233],
[8.42925, 46.43434],
[8.44019, 46.41282],
[8.43674, 46.39811],
[8.44495, 46.39436],
[8.43726, 46.39097],
[8.44493, 46.36432],
[8.43884, 46.35996],
[8.43943, 46.33882],
[8.42058, 46.33028],
[8.40166, 46.30093],
[8.42781, 46.26598],
[8.41628, 46.24743],
[8.44398, 46.23252],
[8.45338, 46.21699],
[8.51350, 46.20625],
[8.51840, 46.18632],
[8.54562, 46.17113],
[8.55710, 46.14945],
[8.57711, 46.14869],
[8.56696, 46.13971],
[8.58984, 46.12380],
[8.59399, 46.11105],
[8.63859, 46.10588],
[8.67608, 46.08471],
[8.72713, 46.08379],
[8.74254, 46.09977],
[8.73697, 46.10385],
[8.74322, 46.08643],
[8.80299, 46.07798],
[8.80563, 46.08198],
[8.82955, 46.06412],
[8.83181, 46.06640],
[8.81567, 46.06239],
[8.80553, 46.03668],
[8.78799, 46.03578],
[8.76081, 45.98367],
[8.82997, 45.96691],
[8.87400, 45.93910],
[8.87889, 45.94488],
[8.86856, 45.92994],
[8.89104, 45.90250],
[8.90084, 45.90185],
[8.90023, 45.88576],
[8.91660, 45.85784],
[8.91776, 45.86432],
[8.90258, 45.86393],
[8.89603, 45.81813],
[8.95055, 45.82389],
[8.96798, 45.81492],
[8.97851, 45.82089],
[8.98094, 45.80700],
[9.04713, 45.80675],
[9.07813, 45.86757],
[9.11395, 45.90802],
[9.09514, 45.91577],
[9.09554, 45.92194],
[9.06511, 45.93762],
[9.03966, 45.94141],
[9.03835, 45.94644],
[9.04077, 45.96484],
[9.01561, 45.97681],
[9.00903, 45.96764],
[9.03058, 45.96743],
[9.03295, 45.98037],
[9.05043, 45.98606],
[9.03567, 46.03332],
[9.03767, 46.03816],
[9.09261, 46.05295],
[9.11460, 46.08440],
[9.09697, 46.11223],
[9.13696, 46.12277],
[9.15541, 46.14174],
[9.17879, 46.15148],
[9.17538, 46.15693],
[9.18763, 46.15209],
[9.22240, 46.17396],
[9.21941, 46.19275],
[9.22199, 46.19588],
[9.24158, 46.20427],
[9.23996, 46.21738],
[9.26541, 46.21975],
[9.27620, 46.26151],
[9.30634, 46.28742],
[9.32590, 46.32360],
[9.32138, 46.35480],
[9.30465, 46.36847],
[9.30280, 46.38244],
[9.30479, 46.42224],
[9.27175, 46.44035],
[9.27148, 46.43699],
[9.29875, 46.44813],
[9.30151, 46.48350],
[9.35472, 46.49395],
[9.34729, 46.48367],
[9.41553, 46.44871],
[9.44545, 46.48236],
[9.44629, 46.49090],
[9.43065, 46.41678],
[9.44139, 46.39908],
[9.43742, 46.37285],
[9.47779, 46.34754],
[9.52505, 46.29412],
[9.63095, 46.26921],
[9.67560, 46.27965],
[9.71997, 46.27460],
[9.75442, 46.32067],
[9.74682, 46.32892],
[9.74788, 46.33267],
[9.74386, 46.33275],
[9.77330, 46.31857],
[9.90058, 46.36030],
[9.93775, 46.34772],
[9.95357, 46.36122],
[9.94365, 46.36158],
[9.94346, 46.35408],
[9.97242, 46.34372],
[9.95677, 46.31269],
[9.97608, 46.30492],
[9.97200, 46.27926],
[10.03397, 46.25862],
[10.03379, 46.24750],
[10.01755, 46.22081],
[10.06981, 46.19799],
[10.10268, 46.20952],
[10.16293, 46.21647],
[10.20196, 46.25104],
[10.17627, 46.30342],
[10.12945, 46.33481],
[10.12930, 46.34616],
[10.15566, 46.35342],
[10.15326, 46.36992],
[10.18428, 46.37782],
[10.18359, 46.42424],
[10.12859, 46.44726],
[10.09071, 46.43575],
[10.06519, 46.44977],
[10.07868, 46.47291],
[10.06866, 46.50644],
[10.08006, 46.52123],
[10.06590, 46.53760],
[10.10397, 46.55660],
[10.12881, 46.58394],
[10.12273, 46.59457],
[10.18664, 46.60602],
[10.22530, 46.60121],
[10.23601, 46.61737],
[10.23366, 46.60998],
[10.21909, 46.59371],
[10.23150, 46.55528],
[10.26598, 46.55805],
[10.26749, 46.55561],
[10.28588, 46.53508],
[10.32193, 46.53357],
[10.34795, 46.52509],
[10.36332, 46.54067],
[10.39200, 46.52586],
[10.41695, 46.53162],
[10.46005, 46.51558],
[10.50242, 46.54441],
[10.51068, 46.62047],
[10.45286, 46.65437],
[10.42122, 46.64936],
[10.40936, 46.68343],
[10.44121, 46.71616],
[10.42684, 46.73106],
[10.46597, 46.74916],
[10.44948, 46.78677],
[10.47088, 46.79448],
[10.49571, 46.85217],
[10.49819, 46.90097],
[10.51930, 46.94333],
[10.45122, 46.96959],
[10.42961, 46.99187],
[10.36883, 47.01133],
[10.31942, 46.99659],
[10.29614, 46.96190],
[10.30164, 46.93940],
[10.22882, 46.94391],
[10.20880, 46.88349],
[10.10985, 46.86229],
[10.09783, 46.87674],
[10.06302, 46.88507],
[10.03086, 46.90539],
[9.89431, 46.94002],
[9.91367, 46.99207],
[9.87773, 47.03818],
[9.80776, 47.04469],
[9.64546, 47.07859],
[9.62711, 47.06933],
[9.60563, 47.07971],
[9.56585, 47.06600],
[9.54752, 47.08221],
[9.47506, 47.07027],
[9.48803, 47.06680],
[9.48799, 47.07364],
[9.48241, 47.06910],
[9.49569, 47.05821],
[9.53721, 47.07597],
[9.54611, 47.10193],
[9.53246, 47.14804],
[9.51594, 47.16896],
[9.51212, 47.18169],
[9.51483, 47.19193],
[9.54521, 47.23790],
[9.55412, 47.26344],
[9.58321, 47.29338],
[9.62301, 47.31685],
[9.64214, 47.34816],
[9.68195, 47.35848],
[9.70516, 47.38657],
[9.68245, 47.41937],
[9.67870, 47.46147],
[9.63582, 47.47300],
[9.60528, 47.48754],
[9.59858, 47.47867],
[9.58823, 47.50049],
[9.56593, 47.55178],
[9.52961, 47.55144],
[9.46606, 47.60812],
[9.26365, 47.67748],
[9.18287, 47.67172],
[9.16605, 47.68244],
[9.14001, 47.68109],
[9.11327, 47.69289],
[9.02119, 47.70255],
[8.94958, 47.67836],
[8.89975, 47.66654],
[8.89795, 47.66783],
[8.88554, 47.69312],
[8.87261, 47.69299],
[8.86982, 47.68178],
[8.89428, 47.68216],
[8.89244, 47.71618],
[8.84461, 47.72120],
[8.84858, 47.72483],
[8.83206, 47.72941],
[8.83794, 47.73015],
[8.81089, 47.75557],
[8.77355, 47.73943],
[8.77639, 47.73552],
[8.74777, 47.72648],
[8.74783, 47.69852],
[8.78469, 47.69617],
[8.78332, 47.70565],
[8.78244, 47.69138],
[8.75138, 47.70259],
[8.75981, 47.72166],
[8.73958, 47.73440],
[8.73986, 47.73298],
[8.73742, 47.73156],
[8.76803, 47.74262],
[8.72301, 47.78174],
[8.70406, 47.77442],
[8.70501, 47.79522],
[8.64642, 47.81649],
[8.63087, 47.77758],
[8.64374, 47.78275],
[8.63879, 47.81284],
[8.57602, 47.81997]
],
&[
[9.52417, 47.28816],
[9.45955, 47.18259],
[9.46640, 47.15683],
[9.48299, 47.13577],
[9.49348, 47.10179],
[9.49302, 47.09549],
[9.44618, 47.07040],
[9.46951, 47.03426],
[9.47623, 47.03862],
[9.48287, 47.03104],
[9.50761, 47.04203],
[9.53098, 47.04810],
[9.54843, 47.03182],
[9.59070, 47.03583],
[9.62475, 47.04700],
[9.63866, 47.06195],
[9.63395, 47.06861],
[9.65517, 47.07316],
[9.65898, 47.10790],
[9.64664, 47.11299],
[9.66025, 47.13163],
[9.64651, 47.14130],
[9.63544, 47.16656],
[9.61762, 47.16494],
[9.61784, 47.17425],
[9.58631, 47.18853],
[9.58907, 47.17816],
[9.59854, 47.17018],
[9.59900, 47.18646],
[9.61115, 47.20500],
[9.58833, 47.23198],
[9.57839, 47.22967],
[9.59365, 47.23984],
[9.58234, 47.25985]
]
]