feat(hikes): worldwide maps via a region-switching tile proxy
Add tile-proxy/: a small Rust (axum) service behind nginx that serves one
canonical XYZ scheme (/{karte,luftbild,dufour}/{z}/{x}/{y}) and, per tile,
picks the provider by geometry — swisstopo when the tile overlaps a
swisstopo-covered region (Switzerland or Liechtenstein, each simplified +
2 km buffer; tile-bbox ∩ polygon at every zoom), otherwise OpenTopoMap
(schematic) / Esri World Imagery (satellite), with an auto-fallback for
border 404s. Includes the region generator (gen-regions.mjs), a Makefile,
nginx caching-proxy + systemd examples, and a README. Listen address is
env-driven (TILE_PROXY_ADDR).
App side:
- New mapTiles.ts is the single source for the proxy URLs + combined
attribution; HikeMap / HikesOverviewMap / EditMap fetch through
maps.bocken.org instead of swisstopo directly, on-map attribution
controls removed, preconnect + footer credits updated (swisstopo /
OpenStreetMap+OpenTopoMap / Esri).
- Region-aware schematic max zoom (isSwissRegion helper): detail map caps
at z17 abroad and hides the CH/LI-only Dufour layer; overview caps at
z18 when a shown hike is abroad.
- Route-builder: add the satellite layer via the same bottom-right layer
popover as the other maps.
This commit is contained in:
@@ -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(' + ')})`
|
||||
);
|
||||
Reference in New Issue
Block a user