#!/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(' + ')})` );