Files
homepage/tile-proxy/scripts/gen-regions.mjs
T
Alexander 2347a02fcb 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.
2026-05-22 16:26:22 +02:00

155 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Generate `src/regions.in` — the polygons the tile proxy treats as
* "swisstopo-covered": Switzerland and Liechtenstein (swisstopo has
* high-quality data for both). A tile overlapping any of them is served by
* swisstopo; everything else by the global providers.
*
* Pipeline per source: largest exterior ring → DouglasPeucker simplify →
* push every vertex ~BUFFER_KM outward (so border tiles still prefer
* swisstopo, which covers a margin past the border). Output is a Rust array of
* rings `&[ &[[lng,lat],…], … ]` consumed via `include!`.
*
* node scripts/gen-regions.mjs [geojson-url-or-path …]
* BUFFER_KM=2 SIMPLIFY_DEG=0.004 node scripts/gen-regions.mjs
*/
import { writeFileSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const SOURCES =
process.argv.slice(2).length > 0
? process.argv.slice(2)
: [
'https://raw.githubusercontent.com/georgique/world-geojson/develop/countries/switzerland.json',
'https://raw.githubusercontent.com/georgique/world-geojson/develop/countries/liechtenstein.json'
];
const BUFFER_KM = Number(process.env.BUFFER_KM ?? 2);
const SIMPLIFY_DEG = Number(process.env.SIMPLIFY_DEG ?? 0.004); // ≈ 440 m
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'regions.in');
async function load(src) {
if (/^https?:/.test(src)) {
const r = await fetch(src);
if (!r.ok) throw new Error(`fetch ${src}: ${r.status}`);
return r.json();
}
return JSON.parse(readFileSync(src, 'utf8'));
}
function exteriorRings(geojson) {
const rings = [];
const visit = (g) => {
if (!g) return;
if (g.type === 'FeatureCollection') g.features.forEach((f) => visit(f.geometry));
else if (g.type === 'Feature') visit(g.geometry);
else if (g.type === 'Polygon') rings.push(g.coordinates[0]);
else if (g.type === 'MultiPolygon') g.coordinates.forEach((p) => rings.push(p[0]));
};
visit(geojson);
return rings;
}
function signedArea(ring) {
let a = 0;
for (let i = 0, n = ring.length, j = n - 1; i < n; j = i++) {
a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
}
return a / 2;
}
function perpDist(p, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const L = dx * dx + dy * dy;
if (L === 0) return Math.hypot(p[0] - a[0], p[1] - a[1]);
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / L;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
function simplify(points, eps) {
if (points.length < 3) return points;
const keep = new Array(points.length).fill(false);
keep[0] = keep[points.length - 1] = true;
const stack = [[0, points.length - 1]];
while (stack.length) {
const [s, e] = stack.pop();
let dmax = 0;
let idx = -1;
for (let i = s + 1; i < e; i++) {
const d = perpDist(points[i], points[s], points[e]);
if (d > dmax) {
dmax = d;
idx = i;
}
}
if (dmax > eps && idx > 0) {
keep[idx] = true;
stack.push([s, idx], [idx, e]);
}
}
return points.filter((_, i) => keep[i]);
}
function bufferOutward(ring, km, lat0) {
const k = Math.cos((lat0 * Math.PI) / 180);
const d = km / 111.32;
const n = ring.length;
const edgeNormal = (a, b) => {
const dx = (b[0] - a[0]) * k;
const dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
return [dy / len, -dx / len];
};
const out = [];
for (let i = 0; i < n; i++) {
const prev = ring[(i - 1 + n) % n];
const cur = ring[i];
const next = ring[(i + 1) % n];
const [ax, ay] = edgeNormal(prev, cur);
const [bx, by] = edgeNormal(cur, next);
let nx = ax + bx;
let ny = ay + by;
const len = Math.hypot(nx, ny) || 1;
nx /= len;
ny /= len;
out.push([cur[0] + (nx * d) / k, cur[1] + ny * d]);
}
return out;
}
async function buildRing(src) {
const gj = await load(src);
let ring = exteriorRings(gj).sort((a, b) => Math.abs(signedArea(b)) - Math.abs(signedArea(a)))[0];
if (!ring) throw new Error(`no polygon rings in ${src}`);
if (ring.length > 1 && ring[0][0] === ring.at(-1)[0] && ring[0][1] === ring.at(-1)[1]) {
ring = ring.slice(0, -1);
}
if (signedArea(ring) < 0) ring = ring.slice().reverse();
ring = simplify(ring, SIMPLIFY_DEG);
const lat0 = ring.reduce((s, p) => s + p[1], 0) / ring.length;
return bufferOutward(ring, BUFFER_KM, lat0);
}
const rings = [];
for (const src of SOURCES) rings.push(await buildRing(src));
const ringLit = (ring) =>
'\t&[\n' + ring.map((p) => `\t\t[${p[0].toFixed(5)}, ${p[1].toFixed(5)}]`).join(',\n') + '\n\t]';
const total = rings.reduce((s, r) => s + r.length, 0);
const all = rings.flat();
const lngs = all.map((p) => p[0]);
const lats = all.map((p) => p[1]);
writeFileSync(
OUT,
`// Auto-generated by scripts/gen-regions.mjs — do not edit by hand.\n` +
`// swisstopo-covered regions (CH + LI), simplified (${SIMPLIFY_DEG}°) + ${BUFFER_KM} km buffer.\n` +
`// ${rings.length} rings, ${total} points · lng [${Math.min(...lngs).toFixed(2)}, ${Math.max(...lngs).toFixed(2)}] · lat [${Math.min(...lats).toFixed(2)}, ${Math.max(...lats).toFixed(2)}]\n` +
`&[\n${rings.map(ringLit).join(',\n')}\n]\n`
);
console.log(
`wrote ${OUT}: ${rings.length} rings, ${total} points (${rings.map((r) => r.length).join(' + ')})`
);