From 2347a02fcb0078cd3855936204451469719bd952 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 22 May 2026 16:26:22 +0200 Subject: [PATCH] feat(hikes): worldwide maps via a region-switching tile proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 2 +- src/lib/components/hikes/HikeMap.svelte | 43 +- .../components/hikes/HikesOverviewMap.svelte | 31 +- .../hikes/route-builder/EditMap.svelte | 191 ++- src/lib/data/mapTiles.ts | 30 + src/lib/hikes/hikeArea.ts | 12 + src/routes/hikes/+page.svelte | 9 +- src/routes/hikes/[slug]/+page.svelte | 16 +- src/routes/hikes/route-builder/+page.svelte | 11 +- tile-proxy/.gitignore | 2 + tile-proxy/Cargo.lock | 1460 +++++++++++++++++ tile-proxy/Cargo.toml | 15 + tile-proxy/Makefile | 20 + tile-proxy/README.md | 96 ++ tile-proxy/deploy/nginx.conf.example | 72 + tile-proxy/deploy/tile-proxy.service | 29 + tile-proxy/scripts/gen-regions.mjs | 154 ++ tile-proxy/src/main.rs | 207 +++ tile-proxy/src/regions.in | 646 ++++++++ 19 files changed, 2993 insertions(+), 53 deletions(-) create mode 100644 src/lib/data/mapTiles.ts create mode 100644 tile-proxy/.gitignore create mode 100644 tile-proxy/Cargo.lock create mode 100644 tile-proxy/Cargo.toml create mode 100644 tile-proxy/Makefile create mode 100644 tile-proxy/README.md create mode 100644 tile-proxy/deploy/nginx.conf.example create mode 100644 tile-proxy/deploy/tile-proxy.service create mode 100644 tile-proxy/scripts/gen-regions.mjs create mode 100644 tile-proxy/src/main.rs create mode 100644 tile-proxy/src/regions.in diff --git a/package.json b/package.json index 92fd0588..54c23986 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.82.1", + "version": "1.83.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/hikes/HikeMap.svelte b/src/lib/components/hikes/HikeMap.svelte index afcbc551..d8b2144d 100644 --- a/src/lib/components/hikes/HikeMap.svelte +++ b/src/lib/components/hikes/HikeMap.svelte @@ -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 = '© swisstopo'; type BaseLayer = 'schematic' | 'aerial' | 'dufour'; - const LAYER_DEFS: Record = { - 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 = $derived({ + schematic: { label: 'Karte', icon: Map, maxZoom: swissRegion ? 19 : 17 }, aerial: { label: 'Luftbild', icon: Satellite, maxZoom: 19 }, // Dufour Map (1845–1864): 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('schematic'); @@ -155,22 +168,22 @@ }); const tileLayers: Record> = { - 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 @@ {#if layerMenuOpen} + + {#if pendingWaypoint}
Klicke auf die Karte, um das Bild zu platzieren. @@ -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; diff --git a/src/lib/data/mapTiles.ts b/src/lib/data/mapTiles.ts new file mode 100644 index 00000000..8d4569ca --- /dev/null +++ b/src/lib/data/mapTiles.ts @@ -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 = + '© swisstopo · ' + + '© OpenStreetMap, ' + + 'OpenTopoMap · ' + + '© Esri'; diff --git a/src/lib/hikes/hikeArea.ts b/src/lib/hikes/hikeArea.ts index bfcc15c1..ec3ae7ae 100644 --- a/src/lib/hikes/hikeArea.ts +++ b/src/lib/hikes/hikeArea.ts @@ -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'; +} diff --git a/src/routes/hikes/+page.svelte b/src/routes/hikes/+page.svelte index 717f386e..f1cc9b94 100644 --- a/src/routes/hikes/+page.svelte +++ b/src/routes/hikes/+page.svelte @@ -253,9 +253,12 @@ credit to appear somewhere on the page. -->
diff --git a/src/routes/hikes/[slug]/+page.svelte b/src/routes/hikes/[slug]/+page.svelte index 100904d1..2c43cbc9 100644 --- a/src/routes/hikes/[slug]/+page.svelte +++ b/src/routes/hikes/[slug]/+page.svelte @@ -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" /> - + @@ -357,6 +361,7 @@ showPrivate {trackColor} {stages} + swissRegion={inSwissRegion} initialCenter={heroPose?.center} initialZoom={heroPose?.zoom} onReady={() => (heroMapReady = true)} @@ -476,7 +481,7 @@
@@ -510,9 +515,10 @@ Kartendaten © - - swisstopo - + swisstopo, + OpenStreetMap, + OpenTopoMap, + Esri diff --git a/src/routes/hikes/route-builder/+page.svelte b/src/routes/hikes/route-builder/+page.svelte index 8cac8bb3..72e98bda 100644 --- a/src/routes/hikes/route-builder/+page.svelte +++ b/src/routes/hikes/route-builder/+page.svelte @@ -335,7 +335,7 @@ - +
@@ -495,9 +495,12 @@ their tile licence still requires the credit to appear on the page. -->
diff --git a/tile-proxy/.gitignore b/tile-proxy/.gitignore new file mode 100644 index 00000000..0c50b62f --- /dev/null +++ b/tile-proxy/.gitignore @@ -0,0 +1,2 @@ +/target +/tile-proxy diff --git a/tile-proxy/Cargo.lock b/tile-proxy/Cargo.lock new file mode 100644 index 00000000..b91ba185 --- /dev/null +++ b/tile-proxy/Cargo.lock @@ -0,0 +1,1460 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tile-proxy" +version = "0.1.0" +dependencies = [ + "axum", + "reqwest", + "tokio", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tile-proxy/Cargo.toml b/tile-proxy/Cargo.toml new file mode 100644 index 00000000..c411bb0e --- /dev/null +++ b/tile-proxy/Cargo.toml @@ -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 diff --git a/tile-proxy/Makefile b/tile-proxy/Makefile new file mode 100644 index 00000000..47c3b0b0 --- /dev/null +++ b/tile-proxy/Makefile @@ -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) diff --git a/tile-proxy/README.md b/tile-proxy/README.md new file mode 100644 index 00000000..80fa32fa --- /dev/null +++ b/tile-proxy/README.md @@ -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, Douglas–Peucker–simplifies 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. diff --git a/tile-proxy/deploy/nginx.conf.example b/tile-proxy/deploy/nginx.conf.example new file mode 100644 index 00000000..1d700502 --- /dev/null +++ b/tile-proxy/deploy/nginx.conf.example @@ -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; } +} diff --git a/tile-proxy/deploy/tile-proxy.service b/tile-proxy/deploy/tile-proxy.service new file mode 100644 index 00000000..26fd6ef0 --- /dev/null +++ b/tile-proxy/deploy/tile-proxy.service @@ -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 diff --git a/tile-proxy/scripts/gen-regions.mjs b/tile-proxy/scripts/gen-regions.mjs new file mode 100644 index 00000000..87699326 --- /dev/null +++ b/tile-proxy/scripts/gen-regions.mjs @@ -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 → Douglas–Peucker 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(' + ')})` +); diff --git a/tile-proxy/src/main.rs b/tile-proxy/src/main.rs new file mode 100644 index 00000000..f724d0c4 --- /dev/null +++ b/tile-proxy/src/main.rs @@ -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 = 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)> { + // 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 { + 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::() + ); + axum::serve(listener, app).await.expect("serve"); +} diff --git a/tile-proxy/src/regions.in b/tile-proxy/src/regions.in new file mode 100644 index 00000000..5c15822a --- /dev/null +++ b/tile-proxy/src/regions.in @@ -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] + ] +]