2347a02fcb
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.
155 lines
5.2 KiB
JavaScript
155 lines
5.2 KiB
JavaScript
#!/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(' + ')})`
|
||
);
|